diff --git a/.gitignore b/.gitignore
index d122d25054..de6a3ac848 100644
--- a/.gitignore
+++ b/.gitignore
@@ -336,3 +336,6 @@ inspectcode
/BenchmarkDotNet.Artifacts
*.GeneratedMSBuildEditorConfig.editorconfig
+
+# Fody (pulled in by Realm) - schema file
+FodyWeavers.xsd
diff --git a/FodyWeavers.xml b/FodyWeavers.xml
new file mode 100644
index 0000000000..cc07b89533
--- /dev/null
+++ b/FodyWeavers.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 3054f19e79..2213b42121 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,7 @@ If your platform is not listed above, there is still a chance you can manually b
osu! is designed to have extensible modular gameplay modes, called "rulesets". Building one of these allows a developer to harness the power of osu! for their own game style. To get started working on a ruleset, we have some templates available [here](https://github.com/ppy/osu/tree/master/Templates).
-You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/issues/5852).
+You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/discussions/13096).
## Developing osu!
diff --git a/osu.Android.props b/osu.Android.props
index 1f60f02fb1..1dc99bb60a 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -51,7 +51,11 @@
-
+
+
+
+
+
diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs
index 0e1ef90737..644facdabc 100644
--- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs
@@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -34,7 +33,7 @@ namespace osu.Game.Rulesets.Catch.UI
// only check the X position; handle all vertical space.
base.ReceivePositionalInputAt(new Vector2(screenSpacePos.X, ScreenSpaceDrawQuad.Centre.Y));
- public CatchPlayfield(BeatmapDifficulty difficulty, Func> createDrawableRepresentation)
+ public CatchPlayfield(BeatmapDifficulty difficulty)
{
var droppedObjectContainer = new Container
{
diff --git a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs
index 9389fa803b..8b6a074426 100644
--- a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs
+++ b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs
@@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Catch.UI
protected override ReplayRecorder CreateReplayRecorder(Score score) => new CatchReplayRecorder(score, (CatchPlayfield)Playfield);
- protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty, CreateDrawableRepresentation);
+ protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty);
public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchPlayfieldAdjustmentContainer();
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs
new file mode 100644
index 0000000000..fd17d11d10
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs
@@ -0,0 +1,260 @@
+// 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 Moq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Edit.Checks;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
+{
+ [TestFixture]
+ public class CheckLowDiffOverlapsTest
+ {
+ private CheckLowDiffOverlaps check;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckLowDiffOverlaps();
+ }
+
+ [Test]
+ public void TestNoOverlapFarApart()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(200, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestNoOverlapClose()
+ {
+ assertShouldProbablyOverlap(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 167, Position = new Vector2(200, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestNoOverlapTooClose()
+ {
+ assertShouldOverlap(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 100, Position = new Vector2(200, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestNoOverlapTooCloseExpert()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 100, Position = new Vector2(200, 0) }
+ }
+ }, DifficultyRating.Expert);
+ }
+
+ [Test]
+ public void TestOverlapClose()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 167, Position = new Vector2(20, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestOverlapFarApart()
+ {
+ assertShouldNotOverlap(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(20, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestAlmostOverlapFarApart()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ // Default circle diameter is 128 px, but part of that is the fade/border of the circle.
+ // We want this to only be a problem when it actually looks like an overlap.
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(125, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestAlmostNotOverlapFarApart()
+ {
+ assertShouldNotOverlap(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(110, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestOverlapFarApartExpert()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(20, 0) }
+ }
+ }, DifficultyRating.Expert);
+ }
+
+ [Test]
+ public void TestOverlapTooFarApart()
+ {
+ // Far apart enough to where the objects are not visible at the same time, and so overlapping is fine.
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 2000, Position = new Vector2(20, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSliderTailOverlapFarApart()
+ {
+ assertShouldNotOverlap(new Beatmap
+ {
+ HitObjects = new List
+ {
+ getSliderMock(startTime: 0, endTime: 500, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
+ new HitCircle { StartTime = 1000, Position = new Vector2(120, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSliderTailOverlapClose()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ getSliderMock(startTime: 0, endTime: 900, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
+ new HitCircle { StartTime = 1000, Position = new Vector2(120, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSliderTailNoOverlapFarApart()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ getSliderMock(startTime: 0, endTime: 500, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
+ new HitCircle { StartTime = 1000, Position = new Vector2(300, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSliderTailNoOverlapClose()
+ {
+ // If these were circles they would need to overlap, but overlapping with slider tails is not required.
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ getSliderMock(startTime: 0, endTime: 900, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
+ new HitCircle { StartTime = 1000, Position = new Vector2(300, 0) }
+ }
+ });
+ }
+
+ private Mock getSliderMock(double startTime, double endTime, Vector2 startPosition, Vector2 endPosition)
+ {
+ var mockSlider = new Mock();
+ mockSlider.SetupGet(s => s.StartTime).Returns(startTime);
+ mockSlider.SetupGet(s => s.Position).Returns(startPosition);
+ mockSlider.SetupGet(s => s.EndPosition).Returns(endPosition);
+ mockSlider.As().Setup(d => d.EndTime).Returns(endTime);
+
+ return mockSlider;
+ }
+
+ private void assertOk(IBeatmap beatmap, DifficultyRating difficultyRating = DifficultyRating.Easy)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ private void assertShouldProbablyOverlap(IBeatmap beatmap, int count = 1)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldProbablyOverlap));
+ }
+
+ private void assertShouldOverlap(IBeatmap beatmap, int count = 1)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldOverlap));
+ }
+
+ private void assertShouldNotOverlap(IBeatmap beatmap, int count = 1)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldNotOverlap));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs
new file mode 100644
index 0000000000..49a6fd12fa
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs
@@ -0,0 +1,324 @@
+// 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 Moq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Edit.Checks;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
+{
+ [TestFixture]
+ public class CheckTimeDistanceEqualityTest
+ {
+ private CheckTimeDistanceEquality check;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckTimeDistanceEquality();
+ }
+
+ [Test]
+ public void TestCirclesEquidistant()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(100, 0) },
+ new HitCircle { StartTime = 1500, Position = new Vector2(150, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesOneSlightlyOff()
+ {
+ assertWarning(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(80, 0) }, // Distance a quite low compared to previous.
+ new HitCircle { StartTime = 1500, Position = new Vector2(130, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesOneOff()
+ {
+ assertProblem(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Twice the regular spacing.
+ new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesTwoOff()
+ {
+ assertProblem(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Twice the regular spacing.
+ new HitCircle { StartTime = 1500, Position = new Vector2(250, 0) } // Also twice the regular spacing.
+ }
+ }, count: 2);
+ }
+
+ [Test]
+ public void TestCirclesStacked()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(50, 0) }, // Stacked, is fine.
+ new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesStacking()
+ {
+ assertWarning(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(50, 0), StackHeight = 1 },
+ new HitCircle { StartTime = 1500, Position = new Vector2(50, 0), StackHeight = 2 },
+ new HitCircle { StartTime = 2000, Position = new Vector2(50, 0), StackHeight = 3 },
+ new HitCircle { StartTime = 2500, Position = new Vector2(50, 0), StackHeight = 4 }, // Ends up far from (50; 0), causing irregular spacing.
+ new HitCircle { StartTime = 3000, Position = new Vector2(100, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesHalfStack()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(55, 0) }, // Basically stacked, so is fine.
+ new HitCircle { StartTime = 1500, Position = new Vector2(105, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesPartialOverlap()
+ {
+ assertProblem(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(65, 0) }, // Really low distance compared to previous.
+ new HitCircle { StartTime = 1500, Position = new Vector2(115, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesSlightlyDifferent()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ // Does not need to be perfect, as long as the distance is approximately correct it's sight-readable.
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(52, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(97, 0) },
+ new HitCircle { StartTime = 1500, Position = new Vector2(165, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesSlowlyChanging()
+ {
+ const float multiplier = 1.2f;
+
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(50 + 50 * multiplier, 0) },
+ // This gap would be a warning if it weren't for the previous pushing the average spacing up.
+ new HitCircle { StartTime = 1500, Position = new Vector2(50 + 50 * multiplier + 50 * multiplier * multiplier, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesQuicklyChanging()
+ {
+ const float multiplier = 1.6f;
+
+ var beatmap = new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(50 + 50 * multiplier, 0) }, // Warning
+ new HitCircle { StartTime = 1500, Position = new Vector2(50 + 50 * multiplier + 50 * multiplier * multiplier, 0) } // Problem
+ }
+ };
+
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(2));
+ Assert.That(issues.First().Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingWarning);
+ Assert.That(issues.Last().Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingProblem);
+ }
+
+ [Test]
+ public void TestCirclesTooFarApart()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 4000, Position = new Vector2(200, 0) }, // 2 seconds apart from previous, so can start from wherever.
+ new HitCircle { StartTime = 4500, Position = new Vector2(250, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestCirclesOneOffExpert()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Jumps are allowed in higher difficulties.
+ new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
+ }
+ }, DifficultyRating.Expert);
+ }
+
+ [Test]
+ public void TestSpinner()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ new Spinner { StartTime = 500, EndTime = 1000 }, // Distance to and from the spinner should be ignored. If it isn't this should give a problem.
+ new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) },
+ new HitCircle { StartTime = 2000, Position = new Vector2(150, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSliders()
+ {
+ assertOk(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ getSliderMock(startTime: 1000, endTime: 1500, startPosition: new Vector2(100, 0), endPosition: new Vector2(150, 0)).Object,
+ getSliderMock(startTime: 2000, endTime: 2500, startPosition: new Vector2(200, 0), endPosition: new Vector2(250, 0)).Object,
+ new HitCircle { StartTime = 2500, Position = new Vector2(300, 0) }
+ }
+ });
+ }
+
+ [Test]
+ public void TestSlidersOneOff()
+ {
+ assertProblem(new Beatmap
+ {
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0, Position = new Vector2(0) },
+ new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
+ getSliderMock(startTime: 1000, endTime: 1500, startPosition: new Vector2(100, 0), endPosition: new Vector2(150, 0)).Object,
+ getSliderMock(startTime: 2000, endTime: 2500, startPosition: new Vector2(250, 0), endPosition: new Vector2(300, 0)).Object, // Twice the spacing.
+ new HitCircle { StartTime = 2500, Position = new Vector2(300, 0) }
+ }
+ });
+ }
+
+ private Mock getSliderMock(double startTime, double endTime, Vector2 startPosition, Vector2 endPosition)
+ {
+ var mockSlider = new Mock();
+ mockSlider.SetupGet(s => s.StartTime).Returns(startTime);
+ mockSlider.SetupGet(s => s.Position).Returns(startPosition);
+ mockSlider.SetupGet(s => s.EndPosition).Returns(endPosition);
+ mockSlider.As().Setup(d => d.EndTime).Returns(endTime);
+
+ return mockSlider;
+ }
+
+ private void assertOk(IBeatmap beatmap, DifficultyRating difficultyRating = DifficultyRating.Easy)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
+ Assert.That(check.Run(context), Is.Empty);
+ }
+
+ private void assertWarning(IBeatmap beatmap, int count = 1)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingWarning));
+ }
+
+ private void assertProblem(IBeatmap beatmap, int count = 1)
+ {
+ var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
+ var issues = check.Run(context).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingProblem));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorHitAnimations.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorHitAnimations.cs
new file mode 100644
index 0000000000..7ffa2c1f94
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorHitAnimations.cs
@@ -0,0 +1,114 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Transforms;
+using osu.Framework.Testing;
+using osu.Framework.Utils;
+using osu.Game.Configuration;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Drawables;
+using osu.Game.Rulesets.Osu.Edit;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.Objects.Drawables;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor
+{
+ [TestFixture]
+ public class TestSceneOsuEditorHitAnimations : TestSceneOsuEditor
+ {
+ [Resolved]
+ private OsuConfigManager config { get; set; }
+
+ [Test]
+ public void TestHitCircleAnimationDisable()
+ {
+ HitCircle hitCircle = null;
+ DrawableHitCircle drawableHitCircle = null;
+
+ AddStep("retrieve first hit circle", () => hitCircle = getHitCircle(0));
+ toggleAnimations(true);
+ seekSmoothlyTo(() => hitCircle.StartTime + 10);
+
+ AddStep("retrieve drawable", () => drawableHitCircle = (DrawableHitCircle)getDrawableObjectFor(hitCircle));
+ assertFutureTransforms(() => drawableHitCircle.CirclePiece, true);
+
+ AddStep("retrieve second hit circle", () => hitCircle = getHitCircle(1));
+ toggleAnimations(false);
+ seekSmoothlyTo(() => hitCircle.StartTime + 10);
+
+ AddStep("retrieve drawable", () => drawableHitCircle = (DrawableHitCircle)getDrawableObjectFor(hitCircle));
+ assertFutureTransforms(() => drawableHitCircle.CirclePiece, false);
+ AddAssert("hit circle has longer fade-out applied", () =>
+ {
+ var alphaTransform = drawableHitCircle.Transforms.Last(t => t.TargetMember == nameof(Alpha));
+ return alphaTransform.EndTime - alphaTransform.StartTime == DrawableOsuEditorRuleset.EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION;
+ });
+ }
+
+ [Test]
+ public void TestSliderAnimationDisable()
+ {
+ Slider slider = null;
+ DrawableSlider drawableSlider = null;
+ DrawableSliderRepeat sliderRepeat = null;
+
+ AddStep("retrieve first slider with repeats", () => slider = getSliderWithRepeats(0));
+ toggleAnimations(true);
+ seekSmoothlyTo(() => slider.StartTime + slider.SpanDuration + 10);
+
+ retrieveDrawables();
+ assertFutureTransforms(() => sliderRepeat, true);
+
+ AddStep("retrieve second slider with repeats", () => slider = getSliderWithRepeats(1));
+ toggleAnimations(false);
+ seekSmoothlyTo(() => slider.StartTime + slider.SpanDuration + 10);
+
+ retrieveDrawables();
+ assertFutureTransforms(() => sliderRepeat.Arrow, false);
+ seekSmoothlyTo(() => slider.GetEndTime());
+ AddAssert("slider has longer fade-out applied", () =>
+ {
+ var alphaTransform = drawableSlider.Transforms.Last(t => t.TargetMember == nameof(Alpha));
+ return alphaTransform.EndTime - alphaTransform.StartTime == DrawableOsuEditorRuleset.EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION;
+ });
+
+ void retrieveDrawables() =>
+ AddStep("retrieve drawables", () =>
+ {
+ drawableSlider = (DrawableSlider)getDrawableObjectFor(slider);
+ sliderRepeat = (DrawableSliderRepeat)getDrawableObjectFor(slider.NestedHitObjects.OfType().First());
+ });
+ }
+
+ private HitCircle getHitCircle(int index)
+ => EditorBeatmap.HitObjects.OfType().ElementAt(index);
+
+ private Slider getSliderWithRepeats(int index)
+ => EditorBeatmap.HitObjects.OfType().Where(s => s.RepeatCount >= 1).ElementAt(index);
+
+ private DrawableHitObject getDrawableObjectFor(HitObject hitObject)
+ => this.ChildrenOfType().Single(ho => ho.HitObject == hitObject);
+
+ private IEnumerable getTransformsRecursively(Drawable drawable)
+ => drawable.ChildrenOfType().SelectMany(d => d.Transforms);
+
+ private void toggleAnimations(bool enabled)
+ => AddStep($"toggle animations {(enabled ? "on" : "off")}", () => config.SetValue(OsuSetting.EditorHitAnimations, enabled));
+
+ private void seekSmoothlyTo(Func targetTime)
+ {
+ AddStep("seek smoothly", () => EditorClock.SeekSmoothlyTo(targetTime.Invoke()));
+ AddUntilStep("wait for seek", () => Precision.AlmostEquals(targetTime.Invoke(), EditorClock.CurrentTime));
+ }
+
+ private void assertFutureTransforms(Func getDrawable, bool hasFutureTransforms)
+ => AddAssert($"object {(hasFutureTransforms ? "has" : "has no")} future transforms",
+ () => getTransformsRecursively(getDrawable()).Any(t => t.EndTime >= EditorClock.CurrentTime) == hasFutureTransforms);
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
index ebe642803b..1efd19f49d 100644
--- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
+++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
@@ -3,6 +3,7 @@
+
diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs
new file mode 100644
index 0000000000..1dd859b5b8
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs
@@ -0,0 +1,109 @@
+// 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.Edit;
+using osu.Game.Rulesets.Edit.Checks.Components;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Objects;
+
+namespace osu.Game.Rulesets.Osu.Edit.Checks
+{
+ public class CheckLowDiffOverlaps : ICheck
+ {
+ // For the lowest difficulties, the osu! Ranking Criteria encourages overlapping ~180 BPM 1/2, but discourages ~180 BPM 1/1.
+ private const double should_overlap_threshold = 150; // 200 BPM 1/2
+ private const double should_probably_overlap_threshold = 175; // 170 BPM 1/2
+ private const double should_not_overlap_threshold = 250; // 120 BPM 1/2 = 240 BPM 1/1
+
+ ///
+ /// Objects need to overlap this much before being treated as an overlap, else it may just be the borders slightly touching.
+ ///
+ private const double overlap_leniency = 5;
+
+ public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Missing or unexpected overlaps");
+
+ public IEnumerable PossibleTemplates => new IssueTemplate[]
+ {
+ new IssueTemplateShouldOverlap(this),
+ new IssueTemplateShouldProbablyOverlap(this),
+ new IssueTemplateShouldNotOverlap(this)
+ };
+
+ public IEnumerable Run(BeatmapVerifierContext context)
+ {
+ // TODO: This should also apply to *lowest difficulty* Normals - they are skipped for now.
+ if (context.InterpretedDifficulty > DifficultyRating.Easy)
+ yield break;
+
+ var hitObjects = context.Beatmap.HitObjects;
+
+ for (int i = 0; i < hitObjects.Count - 1; ++i)
+ {
+ if (!(hitObjects[i] is OsuHitObject hitObject) || hitObject is Spinner)
+ continue;
+
+ if (!(hitObjects[i + 1] is OsuHitObject nextHitObject) || nextHitObject is Spinner)
+ continue;
+
+ var deltaTime = nextHitObject.StartTime - hitObject.GetEndTime();
+ if (deltaTime >= hitObject.TimeFadeIn + hitObject.TimePreempt)
+ // The objects are not visible at the same time (without mods), hence skipping.
+ continue;
+
+ var distanceSq = (hitObject.StackedEndPosition - nextHitObject.StackedPosition).LengthSquared;
+ var diameter = (hitObject.Radius - overlap_leniency) * 2;
+ var diameterSq = diameter * diameter;
+
+ bool areOverlapping = distanceSq < diameterSq;
+
+ // Slider ends do not need to be overlapped because of slider leniency.
+ if (!areOverlapping && !(hitObject is Slider))
+ {
+ if (deltaTime < should_overlap_threshold)
+ yield return new IssueTemplateShouldOverlap(this).Create(deltaTime, hitObject, nextHitObject);
+ else if (deltaTime < should_probably_overlap_threshold)
+ yield return new IssueTemplateShouldProbablyOverlap(this).Create(deltaTime, hitObject, nextHitObject);
+ }
+
+ if (areOverlapping && deltaTime > should_not_overlap_threshold)
+ yield return new IssueTemplateShouldNotOverlap(this).Create(deltaTime, hitObject, nextHitObject);
+ }
+ }
+
+ public abstract class IssueTemplateOverlap : IssueTemplate
+ {
+ protected IssueTemplateOverlap(ICheck check, IssueType issueType, string unformattedMessage)
+ : base(check, issueType, unformattedMessage)
+ {
+ }
+
+ public Issue Create(double deltaTime, params HitObject[] hitObjects) => new Issue(hitObjects, this, deltaTime);
+ }
+
+ public class IssueTemplateShouldOverlap : IssueTemplateOverlap
+ {
+ public IssueTemplateShouldOverlap(ICheck check)
+ : base(check, IssueType.Problem, "These are {0} ms apart and so should be overlapping.")
+ {
+ }
+ }
+
+ public class IssueTemplateShouldProbablyOverlap : IssueTemplateOverlap
+ {
+ public IssueTemplateShouldProbablyOverlap(ICheck check)
+ : base(check, IssueType.Warning, "These are {0} ms apart and so should probably be overlapping.")
+ {
+ }
+ }
+
+ public class IssueTemplateShouldNotOverlap : IssueTemplateOverlap
+ {
+ public IssueTemplateShouldNotOverlap(ICheck check)
+ : base(check, IssueType.Problem, "These are {0} ms apart and so should NOT be overlapping.")
+ {
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs
new file mode 100644
index 0000000000..6420d9558e
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs
@@ -0,0 +1,179 @@
+// 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.Game.Beatmaps;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks.Components;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Objects;
+
+namespace osu.Game.Rulesets.Osu.Edit.Checks
+{
+ public class CheckTimeDistanceEquality : ICheck
+ {
+ ///
+ /// Two objects this many ms apart or more are skipped. (200 BPM 2/1)
+ ///
+ private const double pattern_lifetime = 600;
+
+ ///
+ /// Two objects this distance apart or less are skipped.
+ ///
+ private const double stack_leniency = 12;
+
+ ///
+ /// How long an observation is relevant for comparison. (120 BPM 8/1)
+ ///
+ private const double observation_lifetime = 4000;
+
+ ///
+ /// How different two delta times can be to still be compared. (240 BPM 1/16)
+ ///
+ private const double similar_time_leniency = 16;
+
+ ///
+ /// How many pixels are subtracted from the difference between current and expected distance.
+ ///
+ private const double distance_leniency_absolute_warning = 10;
+
+ ///
+ /// How much of the current distance that the difference can make out.
+ ///
+ private const double distance_leniency_percent_warning = 0.15;
+
+ private const double distance_leniency_absolute_problem = 20;
+ private const double distance_leniency_percent_problem = 0.3;
+
+ public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Object too close or far away from previous");
+
+ public IEnumerable PossibleTemplates => new IssueTemplate[]
+ {
+ new IssueTemplateIrregularSpacingProblem(this),
+ new IssueTemplateIrregularSpacingWarning(this)
+ };
+
+ ///
+ /// Represents an observation of the time and distance between two objects.
+ ///
+ private readonly struct ObservedTimeDistance
+ {
+ public readonly double ObservationTime;
+ public readonly double DeltaTime;
+ public readonly double Distance;
+
+ public ObservedTimeDistance(double observationTime, double deltaTime, double distance)
+ {
+ ObservationTime = observationTime;
+ DeltaTime = deltaTime;
+ Distance = distance;
+ }
+ }
+
+ public IEnumerable Run(BeatmapVerifierContext context)
+ {
+ if (context.InterpretedDifficulty > DifficultyRating.Normal)
+ yield break;
+
+ var prevObservedTimeDistances = new List();
+ var hitObjects = context.Beatmap.HitObjects;
+
+ for (int i = 0; i < hitObjects.Count - 1; ++i)
+ {
+ if (!(hitObjects[i] is OsuHitObject hitObject) || hitObject is Spinner)
+ continue;
+
+ if (!(hitObjects[i + 1] is OsuHitObject nextHitObject) || nextHitObject is Spinner)
+ continue;
+
+ var deltaTime = nextHitObject.StartTime - hitObject.GetEndTime();
+
+ // Ignore objects that are far enough apart in time to not be considered the same pattern.
+ if (deltaTime > pattern_lifetime)
+ continue;
+
+ // Relying on FastInvSqrt is probably good enough here. We'll be taking the difference between distances later, hence square not being sufficient.
+ var distance = (hitObject.StackedEndPosition - nextHitObject.StackedPosition).LengthFast;
+
+ // Ignore stacks and half-stacks, as these are close enough to where they can't be confused for being time-distanced.
+ if (distance < stack_leniency)
+ continue;
+
+ var observedTimeDistance = new ObservedTimeDistance(nextHitObject.StartTime, deltaTime, distance);
+ var expectedDistance = getExpectedDistance(prevObservedTimeDistances, observedTimeDistance);
+
+ if (expectedDistance == 0)
+ {
+ // There was nothing relevant to compare to.
+ prevObservedTimeDistances.Add(observedTimeDistance);
+ continue;
+ }
+
+ if ((Math.Abs(expectedDistance - distance) - distance_leniency_absolute_problem) / distance > distance_leniency_percent_problem)
+ yield return new IssueTemplateIrregularSpacingProblem(this).Create(expectedDistance, distance, hitObject, nextHitObject);
+ else if ((Math.Abs(expectedDistance - distance) - distance_leniency_absolute_warning) / distance > distance_leniency_percent_warning)
+ yield return new IssueTemplateIrregularSpacingWarning(this).Create(expectedDistance, distance, hitObject, nextHitObject);
+ else
+ {
+ // We use `else` here to prevent issues from cascading; an object spaced too far could cause regular spacing to be considered "too short" otherwise.
+ prevObservedTimeDistances.Add(observedTimeDistance);
+ }
+ }
+ }
+
+ private double getExpectedDistance(IEnumerable prevObservedTimeDistances, ObservedTimeDistance observedTimeDistance)
+ {
+ var observations = prevObservedTimeDistances.Count();
+
+ int count = 0;
+ double sum = 0;
+
+ // Looping this in reverse allows us to break before going through all elements, as we're only interested in the most recent ones.
+ for (int i = observations - 1; i >= 0; --i)
+ {
+ var prevObservedTimeDistance = prevObservedTimeDistances.ElementAt(i);
+
+ // Only consider observations within the last few seconds - this allows the map to build spacing up/down over time, but prevents it from being too sudden.
+ if (observedTimeDistance.ObservationTime - prevObservedTimeDistance.ObservationTime > observation_lifetime)
+ break;
+
+ // Only consider observations which have a similar time difference - this leniency allows handling of multi-BPM maps which speed up/down slowly.
+ if (Math.Abs(observedTimeDistance.DeltaTime - prevObservedTimeDistance.DeltaTime) > similar_time_leniency)
+ break;
+
+ count += 1;
+ sum += prevObservedTimeDistance.Distance / Math.Max(prevObservedTimeDistance.DeltaTime, 1);
+ }
+
+ return sum / Math.Max(count, 1) * observedTimeDistance.DeltaTime;
+ }
+
+ public abstract class IssueTemplateIrregularSpacing : IssueTemplate
+ {
+ protected IssueTemplateIrregularSpacing(ICheck check, IssueType issueType)
+ : base(check, issueType, "Expected {0:0} px spacing like previous objects, currently {1:0}.")
+ {
+ }
+
+ public Issue Create(double expected, double actual, params HitObject[] hitObjects) => new Issue(hitObjects, this, expected, actual);
+ }
+
+ public class IssueTemplateIrregularSpacingProblem : IssueTemplateIrregularSpacing
+ {
+ public IssueTemplateIrregularSpacingProblem(ICheck check)
+ : base(check, IssueType.Problem)
+ {
+ }
+ }
+
+ public class IssueTemplateIrregularSpacingWarning : IssueTemplateIrregularSpacing
+ {
+ public IssueTemplateIrregularSpacingWarning(ICheck check)
+ : base(check, IssueType.Warning)
+ {
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs
index aeeae84d14..0e61c02e2d 100644
--- a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs
+++ b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs
@@ -20,6 +20,12 @@ namespace osu.Game.Rulesets.Osu.Edit
{
public class DrawableOsuEditorRuleset : DrawableOsuRuleset
{
+ ///
+ /// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay.
+ /// This allows a mapper to gain better historical context and use recent hitobjects as reference / snap points.
+ ///
+ public const double EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION = 700;
+
public DrawableOsuEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods)
: base(ruleset, beatmap, mods)
{
@@ -46,12 +52,6 @@ namespace osu.Game.Rulesets.Osu.Edit
d.ApplyCustomUpdateState += updateState;
}
- ///
- /// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay.
- /// This allows a mapper to gain better historical context and use recent hitobjects as reference / snap points.
- ///
- private const double editor_hit_object_fade_out_extension = 700;
-
private void updateState(DrawableHitObject hitObject, ArmedState state)
{
if (state == ArmedState.Idle || hitAnimations.Value)
@@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Edit
if (hitObject is DrawableHitCircle circle)
{
circle.ApproachCircle
- .FadeOutFromOne(editor_hit_object_fade_out_extension * 4)
+ .FadeOutFromOne(EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION * 4)
.Expire();
circle.ApproachCircle.ScaleTo(1.1f, 300, Easing.OutQuint);
@@ -69,14 +69,20 @@ namespace osu.Game.Rulesets.Osu.Edit
if (hitObject is IHasMainCirclePiece mainPieceContainer)
{
// clear any explode animation logic.
- mainPieceContainer.CirclePiece.ApplyTransformsAt(hitObject.HitStateUpdateTime, true);
- mainPieceContainer.CirclePiece.ClearTransformsAfter(hitObject.HitStateUpdateTime, true);
+ // this is scheduled after children to ensure that the clear happens after invocations of ApplyCustomUpdateState on the circle piece's nested skinnables.
+ ScheduleAfterChildren(() =>
+ {
+ if (hitObject.HitObject == null) return;
+
+ mainPieceContainer.CirclePiece.ApplyTransformsAt(hitObject.StateUpdateTime, true);
+ mainPieceContainer.CirclePiece.ClearTransformsAfter(hitObject.StateUpdateTime, true);
+ });
}
if (hitObject is DrawableSliderRepeat repeat)
{
- repeat.Arrow.ApplyTransformsAt(hitObject.HitStateUpdateTime, true);
- repeat.Arrow.ClearTransformsAfter(hitObject.HitStateUpdateTime, true);
+ repeat.Arrow.ApplyTransformsAt(hitObject.StateUpdateTime, true);
+ repeat.Arrow.ClearTransformsAfter(hitObject.StateUpdateTime, true);
}
// adjust the visuals of top-level object types to make them stay on screen for longer than usual.
@@ -93,7 +99,7 @@ namespace osu.Game.Rulesets.Osu.Edit
hitObject.RemoveTransform(existing);
using (hitObject.BeginAbsoluteSequence(hitObject.HitStateUpdateTime))
- hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire();
+ hitObject.FadeOut(EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION).Expire();
break;
}
}
diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs
index 04e881fbf3..896e904f3f 100644
--- a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs
+++ b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs
@@ -13,7 +13,12 @@ namespace osu.Game.Rulesets.Osu.Edit
{
private readonly List checks = new List
{
- new CheckOffscreenObjects()
+ // Compose
+ new CheckOffscreenObjects(),
+
+ // Spread
+ new CheckTimeDistanceEquality(),
+ new CheckLowDiffOverlaps()
};
public IEnumerable Run(BeatmapVerifierContext context)
diff --git a/osu.Game.Rulesets.Osu/Mods/IMutateApproachCircles.cs b/osu.Game.Rulesets.Osu/Mods/IMutateApproachCircles.cs
new file mode 100644
index 0000000000..60a5825241
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Mods/IMutateApproachCircles.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.
+
+namespace osu.Game.Rulesets.Osu.Mods
+{
+ ///
+ /// Any mod which affects the animation or visibility of approach circles. Should be used for incompatibility purposes.
+ ///
+ public interface IMutateApproachCircles
+ {
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs
index 074fb7dbed..526e29ad53 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
@@ -11,7 +12,7 @@ using osu.Game.Rulesets.Osu.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Mods
{
- public class OsuModApproachDifferent : Mod, IApplicableToDrawableHitObject
+ public class OsuModApproachDifferent : Mod, IApplicableToDrawableHitObject, IMutateApproachCircles
{
public override string Name => "Approach Different";
public override string Acronym => "AD";
@@ -19,6 +20,8 @@ namespace osu.Game.Rulesets.Osu.Mods
public override double ScoreMultiplier => 1;
public override IconUsage? Icon { get; } = FontAwesome.Regular.Circle;
+ public override Type[] IncompatibleMods => new[] { typeof(IMutateApproachCircles) };
+
[SettingSource("Initial size", "Change the initial size of the approach circle, relative to hit circles.", 0)]
public BindableFloat Scale { get; } = new BindableFloat(4)
{
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs
index 2752feb0a1..16b38cd0b1 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs
@@ -11,15 +11,16 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.Skinning;
namespace osu.Game.Rulesets.Osu.Mods
{
- public class OsuModHidden : ModHidden
+ public class OsuModHidden : ModHidden, IMutateApproachCircles
{
public override string Description => @"Play with no approach circles and fading circles/sliders.";
public override double ScoreMultiplier => 1.06;
- public override Type[] IncompatibleMods => new[] { typeof(OsuModTraceable), typeof(OsuModSpinIn) };
+ public override Type[] IncompatibleMods => new[] { typeof(IMutateApproachCircles) };
private const double fade_in_duration_multiplier = 0.4;
private const double fade_out_duration_multiplier = 0.3;
@@ -110,6 +111,9 @@ namespace osu.Game.Rulesets.Osu.Mods
// hide elements we don't care about.
// todo: hide background
+ spinner.Body.OnSkinChanged += () => hideSpinnerApproachCircle(spinner);
+ hideSpinnerApproachCircle(spinner);
+
using (spinner.BeginAbsoluteSequence(fadeStartTime))
spinner.FadeOut(fadeDuration);
@@ -160,5 +164,15 @@ namespace osu.Game.Rulesets.Osu.Mods
}
}
}
+
+ private static void hideSpinnerApproachCircle(DrawableSpinner spinner)
+ {
+ var approachCircle = (spinner.Body.Drawable as IHasApproachCircle)?.ApproachCircle;
+ if (approachCircle == null)
+ return;
+
+ using (spinner.BeginAbsoluteSequence(spinner.HitObject.StartTime - spinner.HitObject.TimePreempt))
+ approachCircle.Hide();
+ }
}
}
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs
index d1be162f73..6dfabed0df 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs
@@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Mods
///
/// Adjusts the size of hit objects during their fade in animation.
///
- public abstract class OsuModObjectScaleTween : ModWithVisibilityAdjustment
+ public abstract class OsuModObjectScaleTween : ModWithVisibilityAdjustment, IMutateApproachCircles
{
public override ModType Type => ModType.Fun;
@@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods
protected virtual float EndScale => 1;
- public override Type[] IncompatibleMods => new[] { typeof(OsuModSpinIn), typeof(OsuModTraceable) };
+ public override Type[] IncompatibleMods => new[] { typeof(IMutateApproachCircles) };
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)
{
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs
index 96ba58da23..d3ca2973f0 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs
@@ -12,7 +12,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Mods
{
- public class OsuModSpinIn : ModWithVisibilityAdjustment
+ public class OsuModSpinIn : ModWithVisibilityAdjustment, IMutateApproachCircles
{
public override string Name => "Spin In";
public override string Acronym => "SI";
@@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override double ScoreMultiplier => 1;
// todo: this mod should be able to be compatible with hidden with a bit of further implementation.
- public override Type[] IncompatibleMods => new[] { typeof(OsuModObjectScaleTween), typeof(OsuModHidden), typeof(OsuModTraceable) };
+ public override Type[] IncompatibleMods => new[] { typeof(IMutateApproachCircles) };
private const int rotate_offset = 360;
private const float rotate_starting_width = 2;
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs
index 4b0939db16..84263221a7 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs
@@ -11,7 +11,7 @@ using osu.Game.Rulesets.Osu.Skinning.Default;
namespace osu.Game.Rulesets.Osu.Mods
{
- public class OsuModTraceable : ModWithVisibilityAdjustment
+ public class OsuModTraceable : ModWithVisibilityAdjustment, IMutateApproachCircles
{
public override string Name => "Traceable";
public override string Acronym => "TC";
@@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override string Description => "Put your faith in the approach circles...";
public override double ScoreMultiplier => 1;
- public override Type[] IncompatibleMods => new[] { typeof(OsuModHidden), typeof(OsuModSpinIn), typeof(OsuModObjectScaleTween) };
+ public override Type[] IncompatibleMods => new[] { typeof(IMutateApproachCircles) };
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)
{
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs
index ca2e6578db..46fc8f99b2 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs
@@ -12,6 +12,7 @@ using osu.Framework.Input.Bindings;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Judgements;
+using osu.Game.Rulesets.Osu.Skinning;
using osu.Game.Rulesets.Osu.Skinning.Default;
using osu.Game.Rulesets.Scoring;
using osu.Game.Skinning;
@@ -19,7 +20,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
- public class DrawableHitCircle : DrawableOsuHitObject, IHasMainCirclePiece
+ public class DrawableHitCircle : DrawableOsuHitObject, IHasMainCirclePiece, IHasApproachCircle
{
public OsuAction? HitAction => HitArea.HitAction;
protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle;
@@ -28,6 +29,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public HitReceptor HitArea { get; private set; }
public SkinnableDrawable CirclePiece { get; private set; }
+ Drawable IHasApproachCircle.ApproachCircle => ApproachCircle;
+
private Container scaleContainer;
private InputManager inputManager;
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
index 19cee61f26..ec87d3bfdf 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
@@ -29,6 +29,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public new OsuSpinnerJudgementResult Result => (OsuSpinnerJudgementResult)base.Result;
+ public SkinnableDrawable Body { get; private set; }
+
public SpinnerRotationTracker RotationTracker { get; private set; }
private SpinnerSpmCalculator spmCalculator;
@@ -86,7 +88,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
RelativeSizeAxes = Axes.Y,
Children = new Drawable[]
{
- new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinner()),
+ Body = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinner()),
RotationTracker = new SpinnerRotationTracker(this)
}
},
diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs
index fcb544fa5b..46e501758b 100644
--- a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs
+++ b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs
@@ -10,7 +10,6 @@ namespace osu.Game.Rulesets.Osu
Cursor,
CursorTrail,
SliderScorePoint,
- ApproachCircle,
ReverseArrow,
HitCircleText,
SliderHeadHitCircle,
diff --git a/osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.cs b/osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.cs
new file mode 100644
index 0000000000..7fbc5b144b
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.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.Framework.Graphics;
+
+namespace osu.Game.Rulesets.Osu.Skinning
+{
+ ///
+ /// A common interface between implementations which provide an approach circle.
+ ///
+ public interface IHasApproachCircle
+ {
+ ///
+ /// The approach circle drawable.
+ ///
+ Drawable ApproachCircle { get; }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs
index 22fb3aab86..ae8d6a61f8 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs
@@ -55,28 +55,40 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
- Texture = source.GetTexture("spinner-bottom")
+ Texture = source.GetTexture("spinner-bottom"),
},
discTop = new Sprite
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
- Texture = source.GetTexture("spinner-top")
+ Texture = source.GetTexture("spinner-top"),
},
fixedMiddle = new Sprite
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
- Texture = source.GetTexture("spinner-middle")
+ Texture = source.GetTexture("spinner-middle"),
},
spinningMiddle = new Sprite
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
- Texture = source.GetTexture("spinner-middle2")
- }
+ Texture = source.GetTexture("spinner-middle2"),
+ },
}
});
+
+ if (!(source.FindProvider(s => s.GetTexture("spinner-top") != null) is DefaultLegacySkin))
+ {
+ AddInternal(ApproachCircle = new Sprite
+ {
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.Centre,
+ Texture = source.GetTexture("spinner-approachcircle"),
+ Scale = new Vector2(SPRITE_SCALE * 1.86f),
+ Y = SPINNER_Y_CENTRE,
+ });
+ }
}
protected override void UpdateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state)
diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs
index d80e061662..cbe721d21d 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs
@@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
spinnerBlink = source.GetConfig(OsuSkinConfiguration.SpinnerNoBlink)?.Value != true;
- AddRangeInternal(new Drawable[]
+ AddRangeInternal(new[]
{
new Sprite
{
@@ -68,6 +68,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
Origin = Anchor.TopLeft,
Scale = new Vector2(SPRITE_SCALE)
}
+ },
+ ApproachCircle = new Sprite
+ {
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.Centre,
+ Texture = source.GetTexture("spinner-approachcircle"),
+ Scale = new Vector2(SPRITE_SCALE * 1.86f),
+ Y = SPINNER_Y_CENTRE,
}
});
}
diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs
index 959589620b..317649785e 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs
@@ -15,8 +15,10 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
- public abstract class LegacySpinner : CompositeDrawable
+ public abstract class LegacySpinner : CompositeDrawable, IHasApproachCircle
{
+ public const float SPRITE_SCALE = 0.625f;
+
///
/// All constants are in osu!stable's gamefield space, which is shifted 16px downwards.
/// This offset is negated in both osu!stable and osu!lazer to bring all constants into window-space.
@@ -26,12 +28,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
protected const float SPINNER_Y_CENTRE = SPINNER_TOP_OFFSET + 219f;
- protected const float SPRITE_SCALE = 0.625f;
-
private const float spm_hide_offset = 50f;
protected DrawableSpinner DrawableSpinner { get; private set; }
+ public Drawable ApproachCircle { get; protected set; }
+
private Sprite spin;
private Sprite clear;
@@ -175,6 +177,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
spmCounter.MoveToOffset(new Vector2(0, -spm_hide_offset), d.HitObject.TimeFadeIn, Easing.Out);
}
+ using (BeginAbsoluteSequence(d.HitObject.StartTime))
+ ApproachCircle?.ScaleTo(SPRITE_SCALE * 0.1f, d.HitObject.Duration);
+
double spinFadeOutLength = Math.Min(400, d.HitObject.Duration);
using (BeginAbsoluteSequence(drawableHitObject.HitStateUpdateTime - spinFadeOutLength, true))
diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs
index ecb37706b0..2c2c4dc24e 100644
--- a/osu.Game.Tests/Chat/MessageFormatterTests.cs
+++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs
@@ -28,6 +28,8 @@ namespace osu.Game.Tests.Chat
[TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123")]
[TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123/whatever")]
[TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/abc", "https://dev.ppy.sh/beatmapsets/abc")]
+ [TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/discussions", "https://dev.ppy.sh/beatmapsets/discussions")]
+ [TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/discussions/123", "https://dev.ppy.sh/beatmapsets/discussions/123")]
public void TestBeatmapLinks(LinkAction expectedAction, string expectedArg, string link)
{
MessageFormatter.WebsiteRootUrl = "dev.ppy.sh";
diff --git a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs
new file mode 100644
index 0000000000..cac331451b
--- /dev/null
+++ b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs
@@ -0,0 +1,101 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Input.Bindings;
+using osu.Framework.Platform;
+using osu.Game.Database;
+using osu.Game.Input;
+using osu.Game.Input.Bindings;
+using Realms;
+
+namespace osu.Game.Tests.Database
+{
+ [TestFixture]
+ public class TestRealmKeyBindingStore
+ {
+ private NativeStorage storage;
+
+ private RealmKeyBindingStore keyBindingStore;
+
+ private RealmContextFactory realmContextFactory;
+
+ [SetUp]
+ public void SetUp()
+ {
+ var directory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
+
+ storage = new NativeStorage(directory.FullName);
+
+ realmContextFactory = new RealmContextFactory(storage);
+ keyBindingStore = new RealmKeyBindingStore(realmContextFactory);
+ }
+
+ [Test]
+ public void TestDefaultsPopulationAndQuery()
+ {
+ Assert.That(query().Count, Is.EqualTo(0));
+
+ KeyBindingContainer testContainer = new TestKeyBindingContainer();
+
+ keyBindingStore.Register(testContainer);
+
+ Assert.That(query().Count, Is.EqualTo(3));
+
+ Assert.That(query().Where(k => k.ActionInt == (int)GlobalAction.Back).Count, Is.EqualTo(1));
+ Assert.That(query().Where(k => k.ActionInt == (int)GlobalAction.Select).Count, Is.EqualTo(2));
+ }
+
+ private IQueryable query() => realmContextFactory.Context.All();
+
+ [Test]
+ public void TestUpdateViaQueriedReference()
+ {
+ KeyBindingContainer testContainer = new TestKeyBindingContainer();
+
+ keyBindingStore.Register(testContainer);
+
+ var backBinding = query().Single(k => k.ActionInt == (int)GlobalAction.Back);
+
+ Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.Escape }));
+
+ var tsr = ThreadSafeReference.Create(backBinding);
+
+ using (var usage = realmContextFactory.GetForWrite())
+ {
+ var binding = usage.Realm.ResolveReference(tsr);
+ binding.KeyCombination = new KeyCombination(InputKey.BackSpace);
+
+ usage.Commit();
+ }
+
+ Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace }));
+
+ // check still correct after re-query.
+ backBinding = query().Single(k => k.ActionInt == (int)GlobalAction.Back);
+ Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace }));
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ realmContextFactory.Dispose();
+ storage.DeleteDirectory(string.Empty);
+ }
+
+ public class TestKeyBindingContainer : KeyBindingContainer
+ {
+ public override IEnumerable DefaultKeyBindings =>
+ new[]
+ {
+ new KeyBinding(InputKey.Escape, GlobalAction.Back),
+ new KeyBinding(InputKey.Enter, GlobalAction.Select),
+ new KeyBinding(InputKey.Space, GlobalAction.Select),
+ };
+ }
+ }
+}
diff --git a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs
index da0d57f9d1..0ce71696bd 100644
--- a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs
+++ b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs
@@ -44,11 +44,9 @@ namespace osu.Game.Tests.Gameplay
{
TestDrawableHitObject dho = null;
TestLifetimeEntry entry = null;
- AddStep("Create DHO", () =>
+ AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject
{
- dho = new TestDrawableHitObject(null);
- dho.Apply(entry = new TestLifetimeEntry(new HitObject()));
- Child = dho;
+ Entry = entry = new TestLifetimeEntry(new HitObject())
});
AddStep("KeepAlive = true", () =>
@@ -81,12 +79,10 @@ namespace osu.Game.Tests.Gameplay
AddAssert("Lifetime is updated", () => entry.LifetimeStart == -TestLifetimeEntry.INITIAL_LIFETIME_OFFSET);
TestDrawableHitObject dho = null;
- AddStep("Create DHO", () =>
+ AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject
{
- dho = new TestDrawableHitObject(null);
- dho.Apply(entry);
- Child = dho;
- dho.SetLifetimeStartOnApply = true;
+ Entry = entry,
+ SetLifetimeStartOnApply = true
});
AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()));
AddAssert("Lifetime is correct", () => dho.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY && entry.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY);
@@ -97,11 +93,9 @@ namespace osu.Game.Tests.Gameplay
{
TestDrawableHitObject dho = null;
TestLifetimeEntry entry = null;
- AddStep("Create DHO", () =>
+ AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject
{
- dho = new TestDrawableHitObject(null);
- dho.Apply(entry = new TestLifetimeEntry(new HitObject()));
- Child = dho;
+ Entry = entry = new TestLifetimeEntry(new HitObject())
});
AddStep("Set entry lifetime", () =>
@@ -135,7 +129,7 @@ namespace osu.Game.Tests.Gameplay
public bool SetLifetimeStartOnApply;
- public TestDrawableHitObject(HitObject hitObject)
+ public TestDrawableHitObject(HitObject hitObject = null)
: base(hitObject)
{
}
diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs
index 7384471c41..9f27289d7e 100644
--- a/osu.Game.Tests/Mods/ModUtilsTest.cs
+++ b/osu.Game.Tests/Mods/ModUtilsTest.cs
@@ -21,6 +21,14 @@ namespace osu.Game.Tests.Mods
Assert.That(ModUtils.CheckCompatibleSet(new[] { mod.Object }));
}
+ [Test]
+ public void TestModIsCompatibleByItselfWithIncompatibleInterface()
+ {
+ var mod = new Mock();
+ mod.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(IModCompatibilitySpecification) });
+ Assert.That(ModUtils.CheckCompatibleSet(new[] { mod.Object }));
+ }
+
[Test]
public void TestIncompatibleThroughTopLevel()
{
@@ -34,6 +42,20 @@ namespace osu.Game.Tests.Mods
Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False);
}
+ [Test]
+ public void TestIncompatibleThroughInterface()
+ {
+ var mod1 = new Mock();
+ var mod2 = new Mock();
+
+ mod1.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(IModCompatibilitySpecification) });
+ mod2.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(IModCompatibilitySpecification) });
+
+ // Test both orderings.
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.False);
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False);
+ }
+
[Test]
public void TestMultiModIncompatibleWithTopLevel()
{
@@ -149,11 +171,15 @@ namespace osu.Game.Tests.Mods
Assert.That(invalid.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid));
}
- public abstract class CustomMod1 : Mod
+ public abstract class CustomMod1 : Mod, IModCompatibilitySpecification
{
}
- public abstract class CustomMod2 : Mod
+ public abstract class CustomMod2 : Mod, IModCompatibilitySpecification
+ {
+ }
+
+ public interface IModCompatibilitySpecification
{
}
}
diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs
index a763544c37..a540ad7247 100644
--- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs
+++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs
@@ -142,7 +142,10 @@ namespace osu.Game.Tests.NonVisual
foreach (var file in osuStorage.IgnoreFiles)
{
- Assert.That(File.Exists(Path.Combine(originalDirectory, file)));
+ // avoid touching realm files which may be a pipe and break everything.
+ // this is also done locally inside OsuStorage via the IgnoreFiles list.
+ if (file.EndsWith(".ini", StringComparison.Ordinal))
+ Assert.That(File.Exists(Path.Combine(originalDirectory, file)));
Assert.That(storage.Exists(file), Is.False);
}
diff --git a/osu.Game.Tests/Visual/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/TestSceneOsuGame.cs
index b347c39c1e..4e5e8517a4 100644
--- a/osu.Game.Tests/Visual/TestSceneOsuGame.cs
+++ b/osu.Game.Tests/Visual/TestSceneOsuGame.cs
@@ -74,7 +74,6 @@ namespace osu.Game.Tests.Visual
typeof(FileStore),
typeof(ScoreManager),
typeof(BeatmapManager),
- typeof(KeyBindingStore),
typeof(SettingsStore),
typeof(RulesetConfigCache),
typeof(OsuColour),
diff --git a/osu.Game/Database/IHasGuidPrimaryKey.cs b/osu.Game/Database/IHasGuidPrimaryKey.cs
new file mode 100644
index 0000000000..c9cd9b257a
--- /dev/null
+++ b/osu.Game/Database/IHasGuidPrimaryKey.cs
@@ -0,0 +1,16 @@
+// 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 Newtonsoft.Json;
+using Realms;
+
+namespace osu.Game.Database
+{
+ public interface IHasGuidPrimaryKey
+ {
+ [JsonIgnore]
+ [PrimaryKey]
+ Guid ID { get; set; }
+ }
+}
diff --git a/osu.Game/Database/IRealmFactory.cs b/osu.Game/Database/IRealmFactory.cs
new file mode 100644
index 0000000000..c79442134c
--- /dev/null
+++ b/osu.Game/Database/IRealmFactory.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 Realms;
+
+namespace osu.Game.Database
+{
+ public interface IRealmFactory
+ {
+ ///
+ /// The main realm context, bound to the update thread.
+ ///
+ Realm Context { get; }
+
+ ///
+ /// Get a fresh context for read usage.
+ ///
+ RealmContextFactory.RealmUsage GetForRead();
+
+ ///
+ /// Request a context for write usage.
+ /// This method may block if a write is already active on a different thread.
+ ///
+ /// A usage containing a usable context.
+ RealmContextFactory.RealmWriteUsage GetForWrite();
+ }
+}
diff --git a/osu.Game/Database/OsuDbContext.cs b/osu.Game/Database/OsuDbContext.cs
index 2aae62edea..e0c0f56cb3 100644
--- a/osu.Game/Database/OsuDbContext.cs
+++ b/osu.Game/Database/OsuDbContext.cs
@@ -24,13 +24,15 @@ namespace osu.Game.Database
public DbSet BeatmapDifficulty { get; set; }
public DbSet BeatmapMetadata { get; set; }
public DbSet BeatmapSetInfo { get; set; }
- public DbSet DatabasedKeyBinding { get; set; }
public DbSet DatabasedSetting { get; set; }
public DbSet FileInfo { get; set; }
public DbSet RulesetInfo { get; set; }
public DbSet SkinInfo { get; set; }
public DbSet ScoreInfo { get; set; }
+ // migrated to realm
+ public DbSet DatabasedKeyBinding { get; set; }
+
private readonly string connectionString;
private static readonly Lazy logger = new Lazy(() => new OsuDbLoggerFactory());
diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs
new file mode 100644
index 0000000000..ed5931dd2b
--- /dev/null
+++ b/osu.Game/Database/RealmContextFactory.cs
@@ -0,0 +1,208 @@
+// 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.Threading;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Logging;
+using osu.Framework.Platform;
+using osu.Framework.Statistics;
+using osu.Game.Input.Bindings;
+using Realms;
+
+namespace osu.Game.Database
+{
+ public class RealmContextFactory : Component, IRealmFactory
+ {
+ private readonly Storage storage;
+
+ private const string database_name = @"client";
+
+ private const int schema_version = 6;
+
+ ///
+ /// Lock object which is held for the duration of a write operation (via ).
+ ///
+ private readonly object writeLock = new object();
+
+ private static readonly GlobalStatistic reads = GlobalStatistics.Get("Realm", "Get (Read)");
+ private static readonly GlobalStatistic writes = GlobalStatistics.Get("Realm", "Get (Write)");
+ private static readonly GlobalStatistic refreshes = GlobalStatistics.Get("Realm", "Dirty Refreshes");
+ private static readonly GlobalStatistic contexts_created = GlobalStatistics.Get("Realm", "Contexts (Created)");
+ private static readonly GlobalStatistic pending_writes = GlobalStatistics.Get("Realm", "Pending writes");
+ private static readonly GlobalStatistic active_usages = GlobalStatistics.Get("Realm", "Active usages");
+
+ private readonly ManualResetEventSlim blockingResetEvent = new ManualResetEventSlim(true);
+
+ private Realm context;
+
+ public Realm Context
+ {
+ get
+ {
+ if (IsDisposed)
+ throw new InvalidOperationException($"Attempted to access {nameof(Context)} on a disposed context factory");
+
+ if (context == null)
+ {
+ context = createContext();
+ Logger.Log($"Opened realm \"{context.Config.DatabasePath}\" at version {context.Config.SchemaVersion}");
+ }
+
+ // creating a context will ensure our schema is up-to-date and migrated.
+
+ return context;
+ }
+ }
+
+ public RealmContextFactory(Storage storage)
+ {
+ this.storage = storage;
+ }
+
+ public RealmUsage GetForRead()
+ {
+ reads.Value++;
+ return new RealmUsage(this);
+ }
+
+ public RealmWriteUsage GetForWrite()
+ {
+ writes.Value++;
+ pending_writes.Value++;
+
+ Monitor.Enter(writeLock);
+
+ return new RealmWriteUsage(this);
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ if (context?.Refresh() == true)
+ refreshes.Value++;
+ }
+
+ private Realm createContext()
+ {
+ blockingResetEvent.Wait();
+
+ contexts_created.Value++;
+
+ return Realm.GetInstance(new RealmConfiguration(storage.GetFullPath($"{database_name}.realm", true))
+ {
+ SchemaVersion = schema_version,
+ MigrationCallback = onMigration,
+ });
+ }
+
+ private void onMigration(Migration migration, ulong lastSchemaVersion)
+ {
+ switch (lastSchemaVersion)
+ {
+ case 5:
+ // let's keep things simple. changing the type of the primary key is a bit involved.
+ migration.NewRealm.RemoveAll();
+ break;
+ }
+ }
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ BlockAllOperations();
+ }
+
+ public IDisposable BlockAllOperations()
+ {
+ blockingResetEvent.Reset();
+ flushContexts();
+
+ return new InvokeOnDisposal(this, r => endBlockingSection());
+ }
+
+ private void endBlockingSection()
+ {
+ blockingResetEvent.Set();
+ }
+
+ private void flushContexts()
+ {
+ var previousContext = context;
+ context = null;
+
+ // wait for all threaded usages to finish
+ while (active_usages.Value > 0)
+ Thread.Sleep(50);
+
+ previousContext?.Dispose();
+ }
+
+ ///
+ /// A usage of realm from an arbitrary thread.
+ ///
+ public class RealmUsage : IDisposable
+ {
+ public readonly Realm Realm;
+
+ protected readonly RealmContextFactory Factory;
+
+ internal RealmUsage(RealmContextFactory factory)
+ {
+ active_usages.Value++;
+ Factory = factory;
+ Realm = factory.createContext();
+ }
+
+ ///
+ /// Disposes this instance, calling the initially captured action.
+ ///
+ public virtual void Dispose()
+ {
+ Realm?.Dispose();
+ active_usages.Value--;
+ }
+ }
+
+ ///
+ /// A transaction used for making changes to realm data.
+ ///
+ public class RealmWriteUsage : RealmUsage
+ {
+ private readonly Transaction transaction;
+
+ internal RealmWriteUsage(RealmContextFactory factory)
+ : base(factory)
+ {
+ transaction = Realm.BeginWrite();
+ }
+
+ ///
+ /// Commit all changes made in this transaction.
+ ///
+ public void Commit() => transaction.Commit();
+
+ ///
+ /// Revert all changes made in this transaction.
+ ///
+ public void Rollback() => transaction.Rollback();
+
+ ///
+ /// Disposes this instance, calling the initially captured action.
+ ///
+ public override void Dispose()
+ {
+ // rollback if not explicitly committed.
+ transaction?.Dispose();
+
+ base.Dispose();
+
+ Monitor.Exit(Factory.writeLock);
+ pending_writes.Value--;
+ }
+ }
+ }
+}
diff --git a/osu.Game/Database/RealmExtensions.cs b/osu.Game/Database/RealmExtensions.cs
new file mode 100644
index 0000000000..aee36e81c5
--- /dev/null
+++ b/osu.Game/Database/RealmExtensions.cs
@@ -0,0 +1,51 @@
+// 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 AutoMapper;
+using osu.Game.Input.Bindings;
+using Realms;
+
+namespace osu.Game.Database
+{
+ public static class RealmExtensions
+ {
+ private static readonly IMapper mapper = new MapperConfiguration(c =>
+ {
+ c.ShouldMapField = fi => false;
+ c.ShouldMapProperty = pi => pi.SetMethod != null && pi.SetMethod.IsPublic;
+
+ c.CreateMap();
+ }).CreateMapper();
+
+ ///
+ /// Create a detached copy of the each item in the collection.
+ ///
+ /// A list of managed s to detach.
+ /// The type of object.
+ /// A list containing non-managed copies of provided items.
+ public static List Detach(this IEnumerable items) where T : RealmObject
+ {
+ var list = new List();
+
+ foreach (var obj in items)
+ list.Add(obj.Detach());
+
+ return list;
+ }
+
+ ///
+ /// Create a detached copy of the item.
+ ///
+ /// The managed to detach.
+ /// The type of object.
+ /// A non-managed copy of provided item. Will return the provided item if already detached.
+ public static T Detach(this T item) where T : RealmObject
+ {
+ if (!item.IsManaged)
+ return item;
+
+ return mapper.Map(item);
+ }
+ }
+}
diff --git a/osu.Game/Graphics/Containers/OsuHoverContainer.cs b/osu.Game/Graphics/Containers/OsuHoverContainer.cs
index 67af79c763..ac66fd658a 100644
--- a/osu.Game/Graphics/Containers/OsuHoverContainer.cs
+++ b/osu.Game/Graphics/Containers/OsuHoverContainer.cs
@@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osuTK.Graphics;
using System.Collections.Generic;
+using osu.Game.Graphics.UserInterface;
namespace osu.Game.Graphics.Containers
{
@@ -20,7 +21,8 @@ namespace osu.Game.Graphics.Containers
protected virtual IEnumerable EffectTargets => new[] { Content };
- public OsuHoverContainer()
+ public OsuHoverContainer(HoverSampleSet sampleSet = HoverSampleSet.Default)
+ : base(sampleSet)
{
Enabled.ValueChanged += e =>
{
diff --git a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs
index c74ac90a4c..b88f81a143 100644
--- a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs
+++ b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs
@@ -13,13 +13,13 @@ namespace osu.Game.Graphics.UserInterface
[Description("button")]
Button,
- [Description("softer")]
- Soft,
-
[Description("toolbar")]
Toolbar,
- [Description("songselect")]
- SongSelect
+ [Description("tabselect")]
+ TabSelect,
+
+ [Description("scrolltotop")]
+ ScrollToTop
}
}
diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs
index 15fb00ccb0..b97f12df02 100644
--- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs
+++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs
@@ -4,6 +4,8 @@
using System.Linq;
using osuTK.Graphics;
using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Audio.Sample;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -57,6 +59,9 @@ namespace osu.Game.Graphics.UserInterface
{
public override bool HandleNonPositionalInput => State == MenuState.Open;
+ private Sample sampleOpen;
+ private Sample sampleClose;
+
// todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring
public OsuDropdownMenu()
{
@@ -69,9 +74,30 @@ namespace osu.Game.Graphics.UserInterface
ItemsContainer.Padding = new MarginPadding(5);
}
+ [BackgroundDependencyLoader]
+ private void load(AudioManager audio)
+ {
+ sampleOpen = audio.Samples.Get(@"UI/dropdown-open");
+ sampleClose = audio.Samples.Get(@"UI/dropdown-close");
+ }
+
+ // todo: this shouldn't be required after https://github.com/ppy/osu-framework/issues/4519 is fixed.
+ private bool wasOpened;
+
// todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring
- protected override void AnimateOpen() => this.FadeIn(300, Easing.OutQuint);
- protected override void AnimateClose() => this.FadeOut(300, Easing.OutQuint);
+ protected override void AnimateOpen()
+ {
+ wasOpened = true;
+ this.FadeIn(300, Easing.OutQuint);
+ sampleOpen?.Play();
+ }
+
+ protected override void AnimateClose()
+ {
+ this.FadeOut(300, Easing.OutQuint);
+ if (wasOpened)
+ sampleClose?.Play();
+ }
// todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring
protected override void UpdateSize(Vector2 newSize)
@@ -155,7 +181,7 @@ namespace osu.Game.Graphics.UserInterface
nonAccentSelectedColour = Color4.Black.Opacity(0.5f);
updateColours();
- AddInternal(new HoverClickSounds(HoverSampleSet.Soft));
+ AddInternal(new HoverSounds());
}
protected override void UpdateForegroundColour()
@@ -262,7 +288,7 @@ namespace osu.Game.Graphics.UserInterface
},
};
- AddInternal(new HoverClickSounds());
+ AddInternal(new HoverSounds());
}
[BackgroundDependencyLoader]
diff --git a/osu.Game/Graphics/UserInterface/OsuTabControl.cs b/osu.Game/Graphics/UserInterface/OsuTabControl.cs
index 0c220336a5..c447d7f609 100644
--- a/osu.Game/Graphics/UserInterface/OsuTabControl.cs
+++ b/osu.Game/Graphics/UserInterface/OsuTabControl.cs
@@ -172,7 +172,7 @@ namespace osu.Game.Graphics.UserInterface
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
},
- new HoverClickSounds()
+ new HoverClickSounds(HoverSampleSet.TabSelect)
};
}
diff --git a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs
index b66a4a58ce..c6121dcd17 100644
--- a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs
+++ b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs
@@ -4,6 +4,8 @@
using osuTK;
using osuTK.Graphics;
using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Audio.Sample;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@@ -43,6 +45,8 @@ namespace osu.Game.Graphics.UserInterface
}
private const float transition_length = 500;
+ private Sample sampleChecked;
+ private Sample sampleUnchecked;
public OsuTabControlCheckbox()
{
@@ -77,8 +81,7 @@ namespace osu.Game.Graphics.UserInterface
Colour = Color4.White,
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
- },
- new HoverClickSounds()
+ }
};
Current.ValueChanged += selected =>
@@ -91,10 +94,13 @@ namespace osu.Game.Graphics.UserInterface
}
[BackgroundDependencyLoader]
- private void load(OsuColour colours)
+ private void load(OsuColour colours, AudioManager audio)
{
if (accentColour == null)
AccentColour = colours.Blue;
+
+ sampleChecked = audio.Samples.Get(@"UI/check-on");
+ sampleUnchecked = audio.Samples.Get(@"UI/check-off");
}
protected override bool OnHover(HoverEvent e)
@@ -111,6 +117,16 @@ namespace osu.Game.Graphics.UserInterface
base.OnHoverLost(e);
}
+ protected override void OnUserChange(bool value)
+ {
+ base.OnUserChange(value);
+
+ if (value)
+ sampleChecked?.Play();
+ else
+ sampleUnchecked?.Play();
+ }
+
private void updateFade()
{
box.FadeTo(Current.Value || IsHovered ? 1 : 0, transition_length, Easing.OutQuint);
diff --git a/osu.Game/Graphics/UserInterface/PageTabControl.cs b/osu.Game/Graphics/UserInterface/PageTabControl.cs
index 1ba9ad53bb..a218c7bf52 100644
--- a/osu.Game/Graphics/UserInterface/PageTabControl.cs
+++ b/osu.Game/Graphics/UserInterface/PageTabControl.cs
@@ -76,7 +76,7 @@ namespace osu.Game.Graphics.UserInterface
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
},
- new HoverClickSounds()
+ new HoverClickSounds(HoverSampleSet.TabSelect)
};
Active.BindValueChanged(active => Text.Font = Text.Font.With(Typeface.Torus, weight: active.NewValue ? FontWeight.Bold : FontWeight.Medium), true);
diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs
index 7df5d820ee..75130b0f9b 100644
--- a/osu.Game/IO/OsuStorage.cs
+++ b/osu.Game/IO/OsuStorage.cs
@@ -33,12 +33,18 @@ namespace osu.Game.IO
private readonly StorageConfigManager storageConfig;
private readonly Storage defaultStorage;
- public override string[] IgnoreDirectories => new[] { "cache" };
+ public override string[] IgnoreDirectories => new[]
+ {
+ "cache",
+ "client.realm.management"
+ };
public override string[] IgnoreFiles => new[]
{
"framework.ini",
- "storage.ini"
+ "storage.ini",
+ "client.realm.note",
+ "client.realm.lock",
};
public OsuStorage(GameHost host, Storage defaultStorage)
diff --git a/osu.Game/Input/Bindings/DatabasedKeyBinding.cs b/osu.Game/Input/Bindings/DatabasedKeyBinding.cs
index 8c0072c3da..ad3493d0fc 100644
--- a/osu.Game/Input/Bindings/DatabasedKeyBinding.cs
+++ b/osu.Game/Input/Bindings/DatabasedKeyBinding.cs
@@ -8,7 +8,7 @@ using osu.Game.Database;
namespace osu.Game.Input.Bindings
{
[Table("KeyBinding")]
- public class DatabasedKeyBinding : KeyBinding, IHasPrimaryKey
+ public class DatabasedKeyBinding : IKeyBinding, IHasPrimaryKey
{
public int ID { get; set; }
@@ -17,17 +17,23 @@ namespace osu.Game.Input.Bindings
public int? Variant { get; set; }
[Column("Keys")]
- public string KeysString
- {
- get => KeyCombination.ToString();
- private set => KeyCombination = value;
- }
+ public string KeysString { get; set; }
[Column("Action")]
- public int IntAction
+ public int IntAction { get; set; }
+
+ [NotMapped]
+ public KeyCombination KeyCombination
{
- get => (int)Action;
- set => Action = value;
+ get => KeysString;
+ set => KeysString = value.ToString();
+ }
+
+ [NotMapped]
+ public object Action
+ {
+ get => IntAction;
+ set => IntAction = (int)value;
}
}
}
diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs
index 23b09e8fb1..10376c1866 100644
--- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs
+++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs
@@ -3,10 +3,12 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Input.Bindings;
+using osu.Game.Database;
using osu.Game.Rulesets;
-using System.Linq;
+using Realms;
namespace osu.Game.Input.Bindings
{
@@ -21,7 +23,11 @@ namespace osu.Game.Input.Bindings
private readonly int? variant;
- private KeyBindingStore store;
+ private IDisposable realmSubscription;
+ private IQueryable realmKeyBindings;
+
+ [Resolved]
+ private RealmContextFactory realmFactory { get; set; }
public override IEnumerable DefaultKeyBindings => ruleset.CreateInstance().GetDefaultKeyBindings(variant ?? 0);
@@ -42,24 +48,34 @@ namespace osu.Game.Input.Bindings
throw new InvalidOperationException($"{nameof(variant)} can not be null when a non-null {nameof(ruleset)} is provided.");
}
- [BackgroundDependencyLoader]
- private void load(KeyBindingStore keyBindings)
- {
- store = keyBindings;
- }
-
protected override void LoadComplete()
{
+ if (ruleset == null || ruleset.ID.HasValue)
+ {
+ var rulesetId = ruleset?.ID;
+
+ realmKeyBindings = realmFactory.Context.All()
+ .Where(b => b.RulesetID == rulesetId && b.Variant == variant);
+
+ realmSubscription = realmKeyBindings
+ .SubscribeForNotifications((sender, changes, error) =>
+ {
+ // first subscription ignored as we are handling this in LoadComplete.
+ if (changes == null)
+ return;
+
+ ReloadMappings();
+ });
+ }
+
base.LoadComplete();
- store.KeyBindingChanged += ReloadMappings;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
- if (store != null)
- store.KeyBindingChanged -= ReloadMappings;
+ realmSubscription?.Dispose();
}
protected override void ReloadMappings()
@@ -67,17 +83,17 @@ namespace osu.Game.Input.Bindings
var defaults = DefaultKeyBindings.ToList();
if (ruleset != null && !ruleset.ID.HasValue)
- // if the provided ruleset is not stored to the database, we have no way to retrieve custom bindings.
- // fallback to defaults instead.
+ // some tests instantiate a ruleset which is not present in the database.
+ // in these cases we still want key bindings to work, but matching to database instances would result in none being present,
+ // so let's populate the defaults directly.
KeyBindings = defaults;
else
{
- KeyBindings = store.Query(ruleset?.ID, variant)
- .OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.IntAction))
- // this ordering is important to ensure that we read entries from the database in the order
- // enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
- // have been eaten by the music controller due to query order.
- .ToList();
+ KeyBindings = realmKeyBindings.Detach()
+ // this ordering is important to ensure that we read entries from the database in the order
+ // enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
+ // have been eaten by the music controller due to query order.
+ .OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.ActionInt)).ToList();
}
}
}
diff --git a/osu.Game/Input/Bindings/RealmKeyBinding.cs b/osu.Game/Input/Bindings/RealmKeyBinding.cs
new file mode 100644
index 0000000000..334d2da427
--- /dev/null
+++ b/osu.Game/Input/Bindings/RealmKeyBinding.cs
@@ -0,0 +1,39 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Framework.Input.Bindings;
+using osu.Game.Database;
+using Realms;
+
+namespace osu.Game.Input.Bindings
+{
+ [MapTo(nameof(KeyBinding))]
+ public class RealmKeyBinding : RealmObject, IHasGuidPrimaryKey, IKeyBinding
+ {
+ [PrimaryKey]
+ public Guid ID { get; set; } = Guid.NewGuid();
+
+ public int? RulesetID { get; set; }
+
+ public int? Variant { get; set; }
+
+ public KeyCombination KeyCombination
+ {
+ get => KeyCombinationString;
+ set => KeyCombinationString = value.ToString();
+ }
+
+ public object Action
+ {
+ get => ActionInt;
+ set => ActionInt = (int)value;
+ }
+
+ [MapTo(nameof(Action))]
+ public int ActionInt { get; set; }
+
+ [MapTo(nameof(KeyCombination))]
+ public string KeyCombinationString { get; set; }
+ }
+}
diff --git a/osu.Game/Input/KeyBindingStore.cs b/osu.Game/Input/KeyBindingStore.cs
deleted file mode 100644
index 3ef9923487..0000000000
--- a/osu.Game/Input/KeyBindingStore.cs
+++ /dev/null
@@ -1,133 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Linq;
-using osu.Framework.Input.Bindings;
-using osu.Framework.Platform;
-using osu.Game.Database;
-using osu.Game.Input.Bindings;
-using osu.Game.Rulesets;
-
-namespace osu.Game.Input
-{
- public class KeyBindingStore : DatabaseBackedStore
- {
- public event Action KeyBindingChanged;
-
- ///
- /// Keys which should not be allowed for gameplay input purposes.
- ///
- private static readonly IEnumerable banned_keys = new[]
- {
- InputKey.MouseWheelDown,
- InputKey.MouseWheelLeft,
- InputKey.MouseWheelUp,
- InputKey.MouseWheelRight
- };
-
- public KeyBindingStore(DatabaseContextFactory contextFactory, RulesetStore rulesets, Storage storage = null)
- : base(contextFactory, storage)
- {
- using (ContextFactory.GetForWrite())
- {
- foreach (var info in rulesets.AvailableRulesets)
- {
- var ruleset = info.CreateInstance();
- foreach (var variant in ruleset.AvailableVariants)
- insertDefaults(ruleset.GetDefaultKeyBindings(variant), info.ID, variant);
- }
- }
- }
-
- public void Register(KeyBindingContainer manager) => insertDefaults(manager.DefaultKeyBindings);
-
- ///
- /// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action.
- ///
- /// The action to lookup.
- /// A set of display strings for all the user's key configuration for the action.
- public IEnumerable GetReadableKeyCombinationsFor(GlobalAction globalAction)
- {
- foreach (var action in Query().Where(b => (GlobalAction)b.Action == globalAction))
- {
- string str = action.KeyCombination.ReadableString();
-
- // even if found, the readable string may be empty for an unbound action.
- if (str.Length > 0)
- yield return str;
- }
- }
-
- private void insertDefaults(IEnumerable defaults, int? rulesetId = null, int? variant = null)
- {
- using (var usage = ContextFactory.GetForWrite())
- {
- // compare counts in database vs defaults
- foreach (var group in defaults.GroupBy(k => k.Action))
- {
- int count = Query(rulesetId, variant).Count(k => (int)k.Action == (int)group.Key);
- int aimCount = group.Count();
-
- if (aimCount <= count)
- continue;
-
- foreach (var insertable in group.Skip(count).Take(aimCount - count))
- {
- // insert any defaults which are missing.
- usage.Context.DatabasedKeyBinding.Add(new DatabasedKeyBinding
- {
- KeyCombination = insertable.KeyCombination,
- Action = insertable.Action,
- RulesetID = rulesetId,
- Variant = variant
- });
-
- // required to ensure stable insert order (https://github.com/dotnet/efcore/issues/11686)
- usage.Context.SaveChanges();
- }
- }
- }
- }
-
- ///
- /// Retrieve s for a specified ruleset/variant content.
- ///
- /// The ruleset's internal ID.
- /// An optional variant.
- public List Query(int? rulesetId = null, int? variant = null) =>
- ContextFactory.Get().DatabasedKeyBinding.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList();
-
- public void Update(KeyBinding keyBinding)
- {
- using (ContextFactory.GetForWrite())
- {
- var dbKeyBinding = (DatabasedKeyBinding)keyBinding;
-
- Debug.Assert(dbKeyBinding.RulesetID == null || CheckValidForGameplay(keyBinding.KeyCombination));
-
- Refresh(ref dbKeyBinding);
-
- if (dbKeyBinding.KeyCombination.Equals(keyBinding.KeyCombination))
- return;
-
- dbKeyBinding.KeyCombination = keyBinding.KeyCombination;
- }
-
- KeyBindingChanged?.Invoke();
- }
-
- public static bool CheckValidForGameplay(KeyCombination combination)
- {
- foreach (var key in banned_keys)
- {
- if (combination.Keys.Contains(key))
- return false;
- }
-
- return true;
- }
- }
-}
diff --git a/osu.Game/Input/RealmKeyBindingStore.cs b/osu.Game/Input/RealmKeyBindingStore.cs
new file mode 100644
index 0000000000..9089169877
--- /dev/null
+++ b/osu.Game/Input/RealmKeyBindingStore.cs
@@ -0,0 +1,117 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using osu.Framework.Input.Bindings;
+using osu.Game.Database;
+using osu.Game.Input.Bindings;
+using osu.Game.Rulesets;
+
+#nullable enable
+
+namespace osu.Game.Input
+{
+ public class RealmKeyBindingStore
+ {
+ private readonly RealmContextFactory realmFactory;
+
+ public RealmKeyBindingStore(RealmContextFactory realmFactory)
+ {
+ this.realmFactory = realmFactory;
+ }
+
+ ///
+ /// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action.
+ ///
+ /// The action to lookup.
+ /// A set of display strings for all the user's key configuration for the action.
+ public IReadOnlyList GetReadableKeyCombinationsFor(GlobalAction globalAction)
+ {
+ List combinations = new List();
+
+ using (var context = realmFactory.GetForRead())
+ {
+ foreach (var action in context.Realm.All().Where(b => b.RulesetID == null && (GlobalAction)b.ActionInt == globalAction))
+ {
+ string str = action.KeyCombination.ReadableString();
+
+ // even if found, the readable string may be empty for an unbound action.
+ if (str.Length > 0)
+ combinations.Add(str);
+ }
+ }
+
+ return combinations;
+ }
+
+ ///
+ /// Register a new type of , adding default bindings from .
+ ///
+ /// The container to populate defaults from.
+ public void Register(KeyBindingContainer container) => insertDefaults(container.DefaultKeyBindings);
+
+ ///
+ /// Register a ruleset, adding default bindings for each of its variants.
+ ///
+ /// The ruleset to populate defaults from.
+ public void Register(RulesetInfo ruleset)
+ {
+ var instance = ruleset.CreateInstance();
+
+ foreach (var variant in instance.AvailableVariants)
+ insertDefaults(instance.GetDefaultKeyBindings(variant), ruleset.ID, variant);
+ }
+
+ private void insertDefaults(IEnumerable defaults, int? rulesetId = null, int? variant = null)
+ {
+ using (var usage = realmFactory.GetForWrite())
+ {
+ // compare counts in database vs defaults
+ foreach (var defaultsForAction in defaults.GroupBy(k => k.Action))
+ {
+ int existingCount = usage.Realm.All().Count(k => k.RulesetID == rulesetId && k.Variant == variant && k.ActionInt == (int)defaultsForAction.Key);
+
+ if (defaultsForAction.Count() <= existingCount)
+ continue;
+
+ foreach (var k in defaultsForAction.Skip(existingCount))
+ {
+ // insert any defaults which are missing.
+ usage.Realm.Add(new RealmKeyBinding
+ {
+ KeyCombinationString = k.KeyCombination.ToString(),
+ ActionInt = (int)k.Action,
+ RulesetID = rulesetId,
+ Variant = variant
+ });
+ }
+ }
+
+ usage.Commit();
+ }
+ }
+
+ ///
+ /// Keys which should not be allowed for gameplay input purposes.
+ ///
+ private static readonly IEnumerable banned_keys = new[]
+ {
+ InputKey.MouseWheelDown,
+ InputKey.MouseWheelLeft,
+ InputKey.MouseWheelUp,
+ InputKey.MouseWheelRight
+ };
+
+ public static bool CheckValidForGameplay(KeyCombination combination)
+ {
+ foreach (var key in banned_keys)
+ {
+ if (combination.Keys.Contains(key))
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs
index 96bfde8596..3c66f31c58 100644
--- a/osu.Game/Localisation/Language.cs
+++ b/osu.Game/Localisation/Language.cs
@@ -14,9 +14,8 @@ namespace osu.Game.Localisation
// [Description(@"اَلْعَرَبِيَّةُ")]
// ar,
- // TODO: Some accented glyphs are missing. Revisit when adding Inter.
- // [Description(@"Беларуская мова")]
- // be,
+ [Description(@"Беларуская мова")]
+ be,
[Description(@"Български")]
bg,
@@ -30,9 +29,8 @@ namespace osu.Game.Localisation
[Description(@"Deutsch")]
de,
- // TODO: Some accented glyphs are missing. Revisit when adding Inter.
- // [Description(@"Ελληνικά")]
- // el,
+ [Description(@"Ελληνικά")]
+ el,
[Description(@"español")]
es,
@@ -88,15 +86,16 @@ namespace osu.Game.Localisation
[Description(@"ไทย")]
th,
- [Description(@"Tagalog")]
- tl,
+ // Tagalog has no associated localisations yet, and is not supported on Xamarin platforms or Windows versions <10.
+ // Can be revisited if localisations ever arrive.
+ //[Description(@"Tagalog")]
+ //tl,
[Description(@"Türkçe")]
tr,
- // TODO: Some accented glyphs are missing. Revisit when adding Inter.
- // [Description(@"Українська мова")]
- // uk,
+ [Description(@"Українська мова")]
+ uk,
[Description(@"Tiếng Việt")]
vi,
diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs
index df14d7eb1c..faee08742b 100644
--- a/osu.Game/Online/Chat/MessageFormatter.cs
+++ b/osu.Game/Online/Chat/MessageFormatter.cs
@@ -154,6 +154,10 @@ namespace osu.Game.Online.Chat
case "beatmapsets":
case "d":
{
+ if (mainArg == "discussions")
+ // handle discussion links externally for now
+ return new LinkDetails(LinkAction.External, url);
+
if (args.Length > 4 && int.TryParse(args[4], out var id))
// https://osu.ppy.sh/beatmapsets/1154158#osu/2768184
return new LinkDetails(LinkAction.OpenBeatmap, id.ToString());
diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 0c4d035728..32136b8789 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -585,7 +585,15 @@ namespace osu.Game
foreach (var language in Enum.GetValues(typeof(Language)).OfType())
{
var cultureCode = language.ToCultureCode();
- Localisation.AddLanguage(cultureCode, new ResourceManagerLocalisationStore(cultureCode));
+
+ try
+ {
+ Localisation.AddLanguage(cultureCode, new ResourceManagerLocalisationStore(cultureCode));
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, $"Could not load localisations for language \"{cultureCode}\"");
+ }
}
// The next time this is updated is in UpdateAfterChildren, which occurs too late and results
@@ -608,9 +616,9 @@ namespace osu.Game
LocalConfig.LookupKeyBindings = l =>
{
- var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l).ToArray();
+ var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l);
- if (combinations.Length == 0)
+ if (combinations.Count == 0)
return "none";
return string.Join(" or ", combinations);
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index 9c3adba342..3a08ef684f 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -95,7 +95,7 @@ namespace osu.Game
protected RulesetStore RulesetStore { get; private set; }
- protected KeyBindingStore KeyBindingStore { get; private set; }
+ protected RealmKeyBindingStore KeyBindingStore { get; private set; }
protected MenuCursorContainer MenuCursorContainer { get; private set; }
@@ -144,6 +144,8 @@ namespace osu.Game
private DatabaseContextFactory contextFactory;
+ private RealmContextFactory realmFactory;
+
protected override Container Content => content;
private Container content;
@@ -179,6 +181,9 @@ namespace osu.Game
dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage));
+ dependencies.Cache(realmFactory = new RealmContextFactory(Storage));
+ AddInternal(realmFactory);
+
dependencies.CacheAs(Storage);
var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures")));
@@ -190,20 +195,29 @@ namespace osu.Game
AddFont(Resources, @"Fonts/osuFont");
- AddFont(Resources, @"Fonts/Torus-Regular");
- AddFont(Resources, @"Fonts/Torus-Light");
- AddFont(Resources, @"Fonts/Torus-SemiBold");
- AddFont(Resources, @"Fonts/Torus-Bold");
+ AddFont(Resources, @"Fonts/Torus/Torus-Regular");
+ AddFont(Resources, @"Fonts/Torus/Torus-Light");
+ AddFont(Resources, @"Fonts/Torus/Torus-SemiBold");
+ AddFont(Resources, @"Fonts/Torus/Torus-Bold");
- AddFont(Resources, @"Fonts/Noto-Basic");
- AddFont(Resources, @"Fonts/Noto-Hangul");
- AddFont(Resources, @"Fonts/Noto-CJK-Basic");
- AddFont(Resources, @"Fonts/Noto-CJK-Compatibility");
- AddFont(Resources, @"Fonts/Noto-Thai");
+ AddFont(Resources, @"Fonts/Inter/Inter-Regular");
+ AddFont(Resources, @"Fonts/Inter/Inter-RegularItalic");
+ AddFont(Resources, @"Fonts/Inter/Inter-Light");
+ AddFont(Resources, @"Fonts/Inter/Inter-LightItalic");
+ AddFont(Resources, @"Fonts/Inter/Inter-SemiBold");
+ AddFont(Resources, @"Fonts/Inter/Inter-SemiBoldItalic");
+ AddFont(Resources, @"Fonts/Inter/Inter-Bold");
+ AddFont(Resources, @"Fonts/Inter/Inter-BoldItalic");
- AddFont(Resources, @"Fonts/Venera-Light");
- AddFont(Resources, @"Fonts/Venera-Bold");
- AddFont(Resources, @"Fonts/Venera-Black");
+ AddFont(Resources, @"Fonts/Noto/Noto-Basic");
+ AddFont(Resources, @"Fonts/Noto/Noto-Hangul");
+ AddFont(Resources, @"Fonts/Noto/Noto-CJK-Basic");
+ AddFont(Resources, @"Fonts/Noto/Noto-CJK-Compatibility");
+ AddFont(Resources, @"Fonts/Noto/Noto-Thai");
+
+ AddFont(Resources, @"Fonts/Venera/Venera-Light");
+ AddFont(Resources, @"Fonts/Venera/Venera-Bold");
+ AddFont(Resources, @"Fonts/Venera/Venera-Black");
Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY;
@@ -275,7 +289,8 @@ namespace osu.Game
dependencies.Cache(scorePerformanceManager);
AddInternal(scorePerformanceManager);
- dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore));
+ migrateDataToRealm();
+
dependencies.Cache(settingsStore = new SettingsStore(contextFactory));
dependencies.Cache(rulesetConfigCache = new RulesetConfigCache(settingsStore));
@@ -323,7 +338,12 @@ namespace osu.Game
base.Content.Add(CreateScalingContainer().WithChildren(mainContent));
+ KeyBindingStore = new RealmKeyBindingStore(realmFactory);
KeyBindingStore.Register(globalBindings);
+
+ foreach (var r in RulesetStore.AvailableRulesets)
+ KeyBindingStore.Register(r);
+
dependencies.Cache(globalBindings);
PreviewTrackManager previewTrackManager;
@@ -378,8 +398,11 @@ namespace osu.Game
public void Migrate(string path)
{
- contextFactory.FlushConnections();
- (Storage as OsuStorage)?.Migrate(Host.GetStorage(path));
+ using (realmFactory.BlockAllOperations())
+ {
+ contextFactory.FlushConnections();
+ (Storage as OsuStorage)?.Migrate(Host.GetStorage(path));
+ }
}
protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager();
@@ -390,6 +413,34 @@ namespace osu.Game
protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage);
+ private void migrateDataToRealm()
+ {
+ using (var db = contextFactory.GetForWrite())
+ using (var usage = realmFactory.GetForWrite())
+ {
+ var existingBindings = db.Context.DatabasedKeyBinding;
+
+ // only migrate data if the realm database is empty.
+ if (!usage.Realm.All().Any())
+ {
+ foreach (var dkb in existingBindings)
+ {
+ usage.Realm.Add(new RealmKeyBinding
+ {
+ KeyCombinationString = dkb.KeyCombination.ToString(),
+ ActionInt = (int)dkb.Action,
+ RulesetID = dkb.RulesetID,
+ Variant = dkb.Variant
+ });
+ }
+ }
+
+ db.Context.RemoveRange(existingBindings);
+
+ usage.Commit();
+ }
+ }
+
private void onRulesetChanged(ValueChangedEvent r)
{
var dict = new Dictionary>();
diff --git a/osu.Game/Overlays/Comments/CommentsHeader.cs b/osu.Game/Overlays/Comments/CommentsHeader.cs
index 0dd68bbd41..bf80655c3d 100644
--- a/osu.Game/Overlays/Comments/CommentsHeader.cs
+++ b/osu.Game/Overlays/Comments/CommentsHeader.cs
@@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Audio.Sample;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics;
using osu.Framework.Bindables;
@@ -66,6 +68,8 @@ namespace osu.Game.Overlays.Comments
public readonly BindableBool Checked = new BindableBool();
private readonly SpriteIcon checkboxIcon;
+ private Sample sampleChecked;
+ private Sample sampleUnchecked;
public ShowDeletedButton()
{
@@ -93,6 +97,13 @@ namespace osu.Game.Overlays.Comments
});
}
+ [BackgroundDependencyLoader]
+ private void load(AudioManager audio)
+ {
+ sampleChecked = audio.Samples.Get(@"UI/check-on");
+ sampleUnchecked = audio.Samples.Get(@"UI/check-off");
+ }
+
protected override void LoadComplete()
{
Checked.BindValueChanged(isChecked => checkboxIcon.Icon = isChecked.NewValue ? FontAwesome.Solid.CheckSquare : FontAwesome.Regular.Square, true);
@@ -102,6 +113,12 @@ namespace osu.Game.Overlays.Comments
protected override bool OnClick(ClickEvent e)
{
Checked.Value = !Checked.Value;
+
+ if (Checked.Value)
+ sampleChecked?.Play();
+ else
+ sampleUnchecked?.Play();
+
return true;
}
}
diff --git a/osu.Game/Overlays/Comments/HeaderButton.cs b/osu.Game/Overlays/Comments/HeaderButton.cs
index fdc8db35ab..65172aa57c 100644
--- a/osu.Game/Overlays/Comments/HeaderButton.cs
+++ b/osu.Game/Overlays/Comments/HeaderButton.cs
@@ -6,7 +6,6 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
-using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Comments
{
@@ -39,7 +38,6 @@ namespace osu.Game.Overlays.Comments
Origin = Anchor.Centre,
Margin = new MarginPadding { Horizontal = 10 }
},
- new HoverClickSounds(),
});
}
diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs
index 0df3359c28..ef620df171 100644
--- a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs
+++ b/osu.Game/Overlays/KeyBinding/KeyBindingRow.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 osu.Framework.Allocation;
@@ -13,6 +14,7 @@ using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
+using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
@@ -27,7 +29,7 @@ namespace osu.Game.Overlays.KeyBinding
public class KeyBindingRow : Container, IFilterable
{
private readonly object action;
- private readonly IEnumerable bindings;
+ private readonly IEnumerable bindings;
private const float transition_time = 150;
@@ -62,7 +64,7 @@ namespace osu.Game.Overlays.KeyBinding
public IEnumerable FilterTerms => bindings.Select(b => b.KeyCombination.ReadableString()).Prepend(text.Text.ToString());
- public KeyBindingRow(object action, IEnumerable bindings)
+ public KeyBindingRow(object action, List bindings)
{
this.action = action;
this.bindings = bindings;
@@ -72,7 +74,7 @@ namespace osu.Game.Overlays.KeyBinding
}
[Resolved]
- private KeyBindingStore store { get; set; }
+ private RealmContextFactory realmFactory { get; set; }
[BackgroundDependencyLoader]
private void load(OsuColour colours)
@@ -153,7 +155,8 @@ namespace osu.Game.Overlays.KeyBinding
{
var button = buttons[i++];
button.UpdateKeyCombination(d);
- store.Update(button.KeyBinding);
+
+ updateStoreFromButton(button);
}
isDefault.Value = true;
@@ -314,7 +317,7 @@ namespace osu.Game.Overlays.KeyBinding
{
if (bindTarget != null)
{
- store.Update(bindTarget.KeyBinding);
+ updateStoreFromButton(bindTarget);
updateIsDefaultValue();
@@ -361,6 +364,17 @@ namespace osu.Game.Overlays.KeyBinding
if (bindTarget != null) bindTarget.IsBinding = true;
}
+ private void updateStoreFromButton(KeyButton button)
+ {
+ using (var usage = realmFactory.GetForWrite())
+ {
+ var binding = usage.Realm.Find(((IHasGuidPrimaryKey)button.KeyBinding).ID);
+ binding.KeyCombinationString = button.KeyBinding.KeyCombinationString;
+
+ usage.Commit();
+ }
+ }
+
private void updateIsDefaultValue()
{
isDefault.Value = bindings.Select(b => b.KeyCombination).SequenceEqual(Defaults);
@@ -386,7 +400,7 @@ namespace osu.Game.Overlays.KeyBinding
public class KeyButton : Container
{
- public readonly Framework.Input.Bindings.KeyBinding KeyBinding;
+ public readonly RealmKeyBinding KeyBinding;
private readonly Box box;
public readonly OsuSpriteText Text;
@@ -408,8 +422,11 @@ namespace osu.Game.Overlays.KeyBinding
}
}
- public KeyButton(Framework.Input.Bindings.KeyBinding keyBinding)
+ public KeyButton(RealmKeyBinding keyBinding)
{
+ if (keyBinding.IsManaged)
+ throw new ArgumentException("Key binding should not be attached as we make temporary changes", nameof(keyBinding));
+
KeyBinding = keyBinding;
Margin = new MarginPadding(padding);
@@ -478,7 +495,7 @@ namespace osu.Game.Overlays.KeyBinding
public void UpdateKeyCombination(KeyCombination newCombination)
{
- if ((KeyBinding as DatabasedKeyBinding)?.RulesetID != null && !KeyBindingStore.CheckValidForGameplay(newCombination))
+ if (KeyBinding.RulesetID != null && !RealmKeyBindingStore.CheckValidForGameplay(newCombination))
return;
KeyBinding.KeyCombination = newCombination;
diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs b/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs
index 5e1f9d8f75..1fdc1b6574 100644
--- a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs
+++ b/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs
@@ -6,8 +6,9 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
+using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
-using osu.Game.Input;
+using osu.Game.Input.Bindings;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets;
using osuTK;
@@ -31,16 +32,21 @@ namespace osu.Game.Overlays.KeyBinding
}
[BackgroundDependencyLoader]
- private void load(KeyBindingStore store)
+ private void load(RealmContextFactory realmFactory)
{
- var bindings = store.Query(Ruleset?.ID, variant);
+ var rulesetId = Ruleset?.ID;
+
+ List bindings;
+
+ using (var usage = realmFactory.GetForRead())
+ bindings = usage.Realm.All().Where(b => b.RulesetID == rulesetId && b.Variant == variant).Detach();
foreach (var defaultGroup in Defaults.GroupBy(d => d.Action))
{
int intKey = (int)defaultGroup.Key;
// one row per valid action.
- Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => ((int)b.Action).Equals(intKey)))
+ Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => b.ActionInt.Equals(intKey)).ToList())
{
AllowMainMouseButtons = Ruleset != null,
Defaults = defaultGroup.Select(d => d.KeyCombination)
diff --git a/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs b/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs
index 78cd9bdae5..db76581108 100644
--- a/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs
+++ b/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs
@@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Mods
base.OnModSelected(mod);
foreach (var section in ModSectionsContainer.Children)
- section.DeselectTypes(mod.IncompatibleMods, true);
+ section.DeselectTypes(mod.IncompatibleMods, true, mod);
}
}
}
diff --git a/osu.Game/Overlays/Mods/ModButton.cs b/osu.Game/Overlays/Mods/ModButton.cs
index 5e3733cd5e..70424101fd 100644
--- a/osu.Game/Overlays/Mods/ModButton.cs
+++ b/osu.Game/Overlays/Mods/ModButton.cs
@@ -302,7 +302,7 @@ namespace osu.Game.Overlays.Mods
Anchor = Anchor.TopCentre,
Font = OsuFont.GetFont(size: 18)
},
- new HoverClickSounds(buttons: new[] { MouseButton.Left, MouseButton.Right })
+ new HoverSounds()
};
Mod = mod;
diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs
index aa8a5efd39..6e289dc8aa 100644
--- a/osu.Game/Overlays/Mods/ModSection.cs
+++ b/osu.Game/Overlays/Mods/ModSection.cs
@@ -159,12 +159,16 @@ namespace osu.Game.Overlays.Mods
///
/// The types of s which should be deselected.
/// Whether the deselection should happen immediately. Should only be used when required to ensure correct selection flow.
- public void DeselectTypes(IEnumerable modTypes, bool immediate = false)
+ /// If this deselection is triggered by a user selection, this should contain the newly selected type. This type will never be deselected, even if it matches one provided in .
+ public void DeselectTypes(IEnumerable modTypes, bool immediate = false, Mod newSelection = null)
{
foreach (var button in Buttons)
{
if (button.SelectedMod == null) continue;
+ if (button.SelectedMod == newSelection)
+ continue;
+
foreach (var type in modTypes)
{
if (type.IsInstanceOfType(button.SelectedMod))
diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs
index 0004719b87..c5b4cc3645 100644
--- a/osu.Game/Overlays/OverlayScrollContainer.cs
+++ b/osu.Game/Overlays/OverlayScrollContainer.cs
@@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.UserInterface;
using osuTK;
using osuTK.Graphics;
@@ -84,6 +85,7 @@ namespace osu.Game.Overlays
private readonly Box background;
public ScrollToTopButton()
+ : base(HoverSampleSet.ScrollToTop)
{
Size = new Vector2(50);
Alpha = 0;
diff --git a/osu.Game/Overlays/OverlaySortTabControl.cs b/osu.Game/Overlays/OverlaySortTabControl.cs
index b230acca11..d4dde0db3f 100644
--- a/osu.Game/Overlays/OverlaySortTabControl.cs
+++ b/osu.Game/Overlays/OverlaySortTabControl.cs
@@ -148,6 +148,8 @@ namespace osu.Game.Overlays
}
}
});
+
+ AddInternal(new HoverClickSounds());
}
protected override void LoadComplete()
diff --git a/osu.Game/Overlays/OverlayTabControl.cs b/osu.Game/Overlays/OverlayTabControl.cs
index a1cbf2c1e7..578cd703c7 100644
--- a/osu.Game/Overlays/OverlayTabControl.cs
+++ b/osu.Game/Overlays/OverlayTabControl.cs
@@ -99,7 +99,7 @@ namespace osu.Game.Overlays
ExpandedSize = 5f,
CollapsedSize = 0
},
- new HoverClickSounds()
+ new HoverClickSounds(HoverSampleSet.TabSelect)
};
}
diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs
index 1933422dd9..432c52c2e9 100644
--- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs
+++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs
@@ -1,8 +1,8 @@
// 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.Caching;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Graphics;
@@ -13,13 +13,13 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
+using osu.Game.Database;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
-using osu.Game.Input;
using osu.Game.Input.Bindings;
using osuTK;
using osuTK.Graphics;
@@ -76,7 +76,7 @@ namespace osu.Game.Overlays.Toolbar
protected FillFlowContainer Flow;
[Resolved]
- private KeyBindingStore keyBindings { get; set; }
+ private RealmContextFactory realmFactory { get; set; }
protected ToolbarButton()
: base(HoverSampleSet.Toolbar)
@@ -159,27 +159,28 @@ namespace osu.Game.Overlays.Toolbar
};
}
- private readonly Cached tooltipKeyBinding = new Cached();
+ private RealmKeyBinding realmKeyBinding;
- [BackgroundDependencyLoader]
- private void load()
+ protected override void LoadComplete()
{
- keyBindings.KeyBindingChanged += () => tooltipKeyBinding.Invalidate();
+ base.LoadComplete();
+
+ if (Hotkey == null) return;
+
+ realmKeyBinding = realmFactory.Context.All().FirstOrDefault(rkb => rkb.RulesetID == null && rkb.ActionInt == (int)Hotkey.Value);
+
+ if (realmKeyBinding != null)
+ {
+ realmKeyBinding.PropertyChanged += (sender, args) =>
+ {
+ if (args.PropertyName == nameof(realmKeyBinding.KeyCombinationString))
+ updateKeyBindingTooltip();
+ };
+ }
+
updateKeyBindingTooltip();
}
- private void updateKeyBindingTooltip()
- {
- if (tooltipKeyBinding.IsValid)
- return;
-
- var binding = keyBindings.Query().Find(b => (GlobalAction)b.Action == Hotkey);
- var keyBindingString = binding?.KeyCombination.ReadableString();
- keyBindingTooltip.Text = !string.IsNullOrEmpty(keyBindingString) ? $" ({keyBindingString})" : string.Empty;
-
- tooltipKeyBinding.Validate();
- }
-
protected override bool OnMouseDown(MouseDownEvent e) => true;
protected override bool OnClick(ClickEvent e)
@@ -218,6 +219,17 @@ namespace osu.Game.Overlays.Toolbar
public void OnReleased(GlobalAction action)
{
}
+
+ private void updateKeyBindingTooltip()
+ {
+ if (realmKeyBinding != null)
+ {
+ var keyBindingString = realmKeyBinding.KeyCombination.ReadableString();
+
+ if (!string.IsNullOrEmpty(keyBindingString))
+ keyBindingTooltip.Text = $" ({keyBindingString})";
+ }
+ }
}
public class OpaqueBackground : Container
diff --git a/osu.Game/Performance/HighPerformanceSession.cs b/osu.Game/Performance/HighPerformanceSession.cs
index 96e67669c5..661c1046f1 100644
--- a/osu.Game/Performance/HighPerformanceSession.cs
+++ b/osu.Game/Performance/HighPerformanceSession.cs
@@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using System.Runtime;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@@ -11,7 +10,6 @@ namespace osu.Game.Performance
public class HighPerformanceSession : Component
{
private readonly IBindable localUserPlaying = new Bindable();
- private GCLatencyMode originalGCMode;
[BackgroundDependencyLoader]
private void load(OsuGame game)
@@ -34,14 +32,10 @@ namespace osu.Game.Performance
protected virtual void EnableHighPerformanceSession()
{
- originalGCMode = GCSettings.LatencyMode;
- GCSettings.LatencyMode = GCLatencyMode.LowLatency;
}
protected virtual void DisableHighPerformanceSession()
{
- if (GCSettings.LatencyMode == GCLatencyMode.LowLatency)
- GCSettings.LatencyMode = originalGCMode;
}
}
}
diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
index 7fc35fc778..a0717ec38e 100644
--- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
+++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
@@ -156,10 +156,11 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// If null, a hitobject is expected to be later applied via (or automatically via pooling).
///
protected DrawableHitObject([CanBeNull] HitObject initialHitObject = null)
- : base(initialHitObject != null ? new SyntheticHitObjectEntry(initialHitObject) : null)
{
- if (Entry != null)
- ensureEntryHasResult();
+ if (initialHitObject == null) return;
+
+ Entry = new SyntheticHitObjectEntry(initialHitObject);
+ ensureEntryHasResult();
}
[BackgroundDependencyLoader]
diff --git a/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs
index 4440ca8d21..9c6097a048 100644
--- a/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs
+++ b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs
@@ -5,6 +5,7 @@
using System;
using System.Diagnostics;
+using osu.Framework.Graphics;
using osu.Framework.Graphics.Performance;
using osu.Framework.Graphics.Pooling;
@@ -16,14 +17,32 @@ namespace osu.Game.Rulesets.Objects.Pooling
/// The type storing state and controlling this drawable.
public abstract class PoolableDrawableWithLifetime : PoolableDrawable where TEntry : LifetimeEntry
{
+ private TEntry? entry;
+
///
/// The entry holding essential state of this .
///
- public TEntry? Entry { get; private set; }
+ ///
+ /// If a non-null value is set before loading is started, the entry is applied when the loading is completed.
+ /// It is not valid to set an entry while this is loading.
+ ///
+ public TEntry? Entry
+ {
+ get => entry;
+ set
+ {
+ if (LoadState == LoadState.NotLoaded)
+ entry = value;
+ else if (value != null)
+ Apply(value);
+ else if (HasEntryApplied)
+ free();
+ }
+ }
///
/// Whether is applied to this .
- /// When an initial entry is specified in the constructor, is set but not applied until loading is completed.
+ /// When an is set during initialization, it is not applied until loading is completed.
///
protected bool HasEntryApplied { get; private set; }
@@ -65,9 +84,9 @@ namespace osu.Game.Rulesets.Objects.Pooling
{
base.LoadAsyncComplete();
- // Apply the initial entry given in the constructor.
+ // Apply the initial entry.
if (Entry != null && !HasEntryApplied)
- Apply(Entry);
+ apply(Entry);
}
///
@@ -76,16 +95,10 @@ namespace osu.Game.Rulesets.Objects.Pooling
///
public void Apply(TEntry entry)
{
- if (HasEntryApplied)
- free();
+ if (LoadState == LoadState.Loading)
+ throw new InvalidOperationException($"Cannot apply a new {nameof(TEntry)} while currently loading.");
- Entry = entry;
- entry.LifetimeChanged += setLifetimeFromEntry;
- setLifetimeFromEntry(entry);
-
- OnApply(entry);
-
- HasEntryApplied = true;
+ apply(entry);
}
protected sealed override void FreeAfterUse()
@@ -111,6 +124,20 @@ namespace osu.Game.Rulesets.Objects.Pooling
{
}
+ private void apply(TEntry entry)
+ {
+ if (HasEntryApplied)
+ free();
+
+ this.entry = entry;
+ entry.LifetimeChanged += setLifetimeFromEntry;
+ setLifetimeFromEntry(entry);
+
+ OnApply(entry);
+
+ HasEntryApplied = true;
+ }
+
private void free()
{
Debug.Assert(Entry != null && HasEntryApplied);
@@ -118,7 +145,7 @@ namespace osu.Game.Rulesets.Objects.Pooling
OnFree(Entry);
Entry.LifetimeChanged -= setLifetimeFromEntry;
- Entry = null;
+ entry = null;
base.LifetimeStart = double.MinValue;
base.LifetimeEnd = double.MaxValue;
diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs
index 0a34ca9598..1f12f3dfeb 100644
--- a/osu.Game/Rulesets/RulesetStore.cs
+++ b/osu.Game/Rulesets/RulesetStore.cs
@@ -96,13 +96,25 @@ namespace osu.Game.Rulesets
context.SaveChanges();
- // add any other modes
var existingRulesets = context.RulesetInfo.ToList();
+ // add any other rulesets which have assemblies present but are not yet in the database.
foreach (var r in instances.Where(r => !(r is ILegacyRuleset)))
{
if (existingRulesets.FirstOrDefault(ri => ri.InstantiationInfo.Equals(r.RulesetInfo.InstantiationInfo, StringComparison.Ordinal)) == null)
- context.RulesetInfo.Add(r.RulesetInfo);
+ {
+ var existingSameShortName = existingRulesets.FirstOrDefault(ri => ri.ShortName == r.RulesetInfo.ShortName);
+
+ if (existingSameShortName != null)
+ {
+ // even if a matching InstantiationInfo was not found, there may be an existing ruleset with the same ShortName.
+ // this generally means the user or ruleset provider has renamed their dll but the underlying ruleset is *likely* the same one.
+ // in such cases, update the instantiation info of the existing entry to point to the new one.
+ existingSameShortName.InstantiationInfo = r.RulesetInfo.InstantiationInfo;
+ }
+ else
+ context.RulesetInfo.Add(r.RulesetInfo);
+ }
}
context.SaveChanges();
diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs
index e3b9ad5641..e6cd2aa3dc 100644
--- a/osu.Game/Rulesets/UI/RulesetInputManager.cs
+++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs
@@ -177,7 +177,7 @@ namespace osu.Game.Rulesets.UI
{
base.ReloadMappings();
- KeyBindings = KeyBindings.Where(b => KeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList();
+ KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList();
}
}
}
diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index 70cac9d27a..cadcc474b2 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -295,12 +295,12 @@ namespace osu.Game.Screens.Play
DimmableStoryboard.HasStoryboardEnded.ValueChanged += storyboardEnded =>
{
- if (storyboardEnded.NewValue && resultsDisplayDelegate == null)
- updateCompletionState();
+ if (storyboardEnded.NewValue)
+ progressToResults(true);
};
// Bind the judgement processors to ourselves
- ScoreProcessor.HasCompleted.BindValueChanged(_ => updateCompletionState());
+ ScoreProcessor.HasCompleted.BindValueChanged(scoreCompletionChanged);
HealthProcessor.Failed += onFail;
foreach (var mod in Mods.Value.OfType())
@@ -374,7 +374,7 @@ namespace osu.Game.Screens.Play
},
skipOutroOverlay = new SkipOverlay(Beatmap.Value.Storyboard.LatestEventTime ?? 0)
{
- RequestSkip = () => updateCompletionState(true),
+ RequestSkip = () => progressToResults(false),
Alpha = 0
},
FailOverlay = new FailOverlay
@@ -643,9 +643,8 @@ namespace osu.Game.Screens.Play
///
/// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime.
///
- /// If in a state where a storyboard outro is to be played, offers the choice of skipping beyond it.
/// Thrown if this method is called more than once without changing state.
- private void updateCompletionState(bool skipStoryboardOutro = false)
+ private void scoreCompletionChanged(ValueChangedEvent completed)
{
// If this player instance is in the middle of an exit, don't attempt any kind of state update.
if (!this.IsCurrentScreen())
@@ -656,7 +655,7 @@ namespace osu.Game.Screens.Play
// Currently, even if this scenario is hit, prepareScoreForDisplay has already been queued (and potentially run).
// In scenarios where rewinding is possible (replay, spectating) this is a non-issue as no submission/import work is done,
// but it still doesn't feel right that this exists here.
- if (!ScoreProcessor.HasCompleted.Value)
+ if (!completed.NewValue)
{
resultsDisplayDelegate?.Cancel();
resultsDisplayDelegate = null;
@@ -666,9 +665,6 @@ namespace osu.Game.Screens.Play
return;
}
- if (resultsDisplayDelegate != null)
- throw new InvalidOperationException(@$"{nameof(updateCompletionState)} should never be fired more than once.");
-
// Only show the completion screen if the player hasn't failed
if (HealthProcessor.HasFailed)
return;
@@ -683,27 +679,25 @@ namespace osu.Game.Screens.Play
if (!Configuration.ShowResults)
return;
- // Asynchronously run score preparation operations (database import, online submission etc.).
prepareScoreForDisplayTask ??= Task.Run(prepareScoreForResults);
- if (skipStoryboardOutro)
- {
- scheduleCompletion();
- return;
- }
-
bool storyboardHasOutro = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value;
if (storyboardHasOutro)
{
+ // if the current beatmap has a storyboard, the progression to results will be handled by the storyboard ending
+ // or the user pressing the skip outro button.
skipOutroOverlay.Show();
return;
}
- using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY))
- scheduleCompletion();
+ progressToResults(true);
}
+ ///
+ /// Asynchronously run score preparation operations (database import, online submission etc.).
+ ///
+ /// The final score.
private async Task prepareScoreForResults()
{
try
@@ -727,18 +721,44 @@ namespace osu.Game.Screens.Play
return Score.ScoreInfo;
}
- private void scheduleCompletion() => resultsDisplayDelegate = Schedule(() =>
+ ///
+ /// Queue the results screen for display.
+ ///
+ ///
+ /// A final display will only occur once all work is completed in . This means that even after calling this method, the results screen will never be shown until ScoreProcessor.HasCompleted becomes .
+ ///
+ /// Calling this method multiple times will have no effect.
+ ///
+ /// Whether a minimum delay () should be added before the screen is displayed.
+ private void progressToResults(bool withDelay)
{
- if (!prepareScoreForDisplayTask.IsCompleted)
- {
- scheduleCompletion();
+ if (resultsDisplayDelegate != null)
+ // Note that if progressToResults is called one withDelay=true and then withDelay=false, this no-delay timing will not be
+ // accounted for. shouldn't be a huge concern (a user pressing the skip button after a results progression has already been queued
+ // may take x00 more milliseconds than expected in the very rare edge case).
+ //
+ // If required we can handle this more correctly by rescheduling here.
return;
- }
- // screen may be in the exiting transition phase.
- if (this.IsCurrentScreen())
+ double delay = withDelay ? RESULTS_DISPLAY_DELAY : 0;
+
+ resultsDisplayDelegate = new ScheduledDelegate(() =>
+ {
+ if (prepareScoreForDisplayTask?.IsCompleted != true)
+ // If the asynchronous preparation has not completed, keep repeating this delegate.
+ return;
+
+ resultsDisplayDelegate?.Cancel();
+
+ if (!this.IsCurrentScreen())
+ // This player instance may already be in the process of exiting.
+ return;
+
this.Push(CreateResults(prepareScoreForDisplayTask.Result));
- });
+ }, Time.Current + delay, 50);
+
+ Scheduler.Add(resultsDisplayDelegate);
+ }
protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value;
@@ -936,14 +956,6 @@ namespace osu.Game.Screens.Play
{
screenSuspension?.Expire();
- // if the results screen is prepared to be displayed, forcefully show it on an exit request.
- // usually if a user has completed a play session they do want to see results. and if they don't they can hit the same key a second time.
- if (resultsDisplayDelegate != null && !resultsDisplayDelegate.Cancelled && !resultsDisplayDelegate.Completed)
- {
- resultsDisplayDelegate.RunTask();
- return true;
- }
-
// EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous.
// To resolve test failures, forcefully end playing synchronously when this screen exits.
// Todo: Replace this with a more permanent solution once osu-framework has a synchronous cleanup method.
diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs
index afb3943a09..c3fbd767ff 100644
--- a/osu.Game/Screens/Select/FooterButton.cs
+++ b/osu.Game/Screens/Select/FooterButton.cs
@@ -66,7 +66,7 @@ namespace osu.Game.Screens.Select
private readonly Box light;
public FooterButton()
- : base(HoverSampleSet.SongSelect)
+ : base(HoverSampleSet.Button)
{
AutoSizeAxes = Axes.Both;
Shear = SHEAR;
diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
index ca041da801..8a31e4576a 100644
--- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
+++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
@@ -25,7 +25,7 @@ namespace osu.Game.Storyboards.Drawables
///
public IBindable HasStoryboardEnded => hasStoryboardEnded;
- private readonly BindableBool hasStoryboardEnded = new BindableBool();
+ private readonly BindableBool hasStoryboardEnded = new BindableBool(true);
protected override Container Content { get; }
diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs
index 1c3558fc90..98766cb844 100644
--- a/osu.Game/Utils/ModUtils.cs
+++ b/osu.Game/Utils/ModUtils.cs
@@ -60,6 +60,9 @@ namespace osu.Game.Utils
{
foreach (var invalid in combination.Where(m => type.IsInstanceOfType(m)))
{
+ if (invalid == mod)
+ continue;
+
invalidMods ??= new List();
invalidMods.Add(invalid);
}
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 68ffb87c6c..3c52405f8e 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -18,6 +18,7 @@
+
@@ -34,8 +35,9 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index 8aa79762fc..3689ce51f2 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -71,7 +71,7 @@
-
+
@@ -99,5 +99,6 @@
+