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/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/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/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs index 2ac6e6ff22..52d8230fb6 100644 --- a/osu.Game/Extensions/DrawableExtensions.cs +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Threading; using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Extensions @@ -57,6 +58,9 @@ namespace osu.Game.Extensions component.Anchor = info.Anchor; component.Origin = info.Origin; + if (component is ISkinnableDrawable skinnable) + skinnable.UsesFixedAnchor = info.UsesFixedAnchor; + if (component is Container container) { foreach (var child in info.Children) diff --git a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs index 45ba05e036..324e5d43b5 100644 --- a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs @@ -12,6 +12,8 @@ namespace osu.Game.Screens.Play.HUD [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } + public bool UsesFixedAnchor { get; set; } + [BackgroundDependencyLoader] private void load(OsuColour colours) { diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index c4575c5ad0..718ae24cf1 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -17,6 +17,8 @@ namespace osu.Game.Screens.Play.HUD [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } + public bool UsesFixedAnchor { get; set; } + public DefaultComboCounter() { Current.Value = DisplayedCount = 0; diff --git a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs index ed297f0ffc..4f93868a66 100644 --- a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs @@ -72,6 +72,8 @@ namespace osu.Game.Screens.Play.HUD } } + public bool UsesFixedAnchor { get; set; } + public DefaultHealthDisplay() { Size = new Vector2(1, 5); diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index 16e3642181..63de5c8de5 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -20,6 +20,8 @@ namespace osu.Game.Screens.Play.HUD [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } + public bool UsesFixedAnchor { get; set; } + [BackgroundDependencyLoader] private void load(OsuColour colours) { diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs index 9844b9f10d..788ba5be8c 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs @@ -22,6 +22,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters [Resolved] private OsuColour colours { get; set; } + public bool UsesFixedAnchor { get; set; } + [BackgroundDependencyLoader(true)] private void load(DrawableRuleset drawableRuleset) { diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs index 1737634e31..acff949353 100644 --- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs @@ -59,6 +59,8 @@ namespace osu.Game.Screens.Play.HUD set => counterContainer.Alpha = value ? 1 : 0; } + public bool UsesFixedAnchor { get; set; } + public LegacyComboCounter() { AutoSizeAxes = Axes.Both; diff --git a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs index e08044b14c..b64e5ca98f 100644 --- a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs +++ b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs @@ -32,6 +32,9 @@ namespace osu.Game.Screens.Play.HUD public Anchor Origin { get; set; } + /// + public bool UsesFixedAnchor { get; set; } + public List Children { get; } = new List(); [JsonConstructor] @@ -53,6 +56,9 @@ namespace osu.Game.Screens.Play.HUD Anchor = component.Anchor; Origin = component.Origin; + if (component is ISkinnableDrawable skinnable) + UsesFixedAnchor = skinnable.UsesFixedAnchor; + if (component is Container container) { foreach (var child in container.OfType().OfType()) diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs index cab44c7473..bd861dc598 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/SongProgress.cs @@ -78,6 +78,8 @@ namespace osu.Game.Screens.Play private IClock referenceClock; + public bool UsesFixedAnchor { get; set; } + public SongProgress() { RelativeSizeAxes = Axes.X; diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs index 9cca0ba2c7..23f36ffe5b 100644 --- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs @@ -149,13 +149,21 @@ namespace osu.Game.Skinning.Editor { foreach (var c in SelectedBlueprints) { - Drawable drawable = (Drawable)c.Item; + var item = c.Item; + Drawable drawable = (Drawable)item; + drawable.Position += drawable.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta); + + if (item.UsesFixedAnchor) continue; + + applyClosestAnchor(drawable); } return true; } + private static void applyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable)); + protected override void OnSelectionChanged() { base.OnSelectionChanged(); @@ -171,20 +179,27 @@ namespace osu.Game.Skinning.Editor protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { + var closestItem = new TernaryStateRadioMenuItem("Closest", MenuItemType.Standard, _ => applyClosestAnchors()) + { + State = { Value = GetStateFromSelection(selection, c => !c.Item.UsesFixedAnchor) } + }; + yield return new OsuMenuItem("Anchor") { - Items = createAnchorItems(d => d.Anchor, applyAnchor).ToArray() + Items = createAnchorItems((d, a) => d.UsesFixedAnchor && ((Drawable)d).Anchor == a, applyFixedAnchors) + .Prepend(closestItem) + .ToArray() }; yield return new OsuMenuItem("Origin") { - Items = createAnchorItems(d => d.Origin, applyOrigin).ToArray() + Items = createAnchorItems((d, o) => ((Drawable)d).Origin == o, applyOrigins).ToArray() }; foreach (var item in base.GetContextMenuItemsForSelection(selection)) yield return item; - IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) + IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) { var displayableAnchors = new[] { @@ -198,12 +213,11 @@ namespace osu.Game.Skinning.Editor Anchor.BottomCentre, Anchor.BottomRight, }; - return displayableAnchors.Select(a => { return new TernaryStateRadioMenuItem(a.ToString(), MenuItemType.Standard, _ => applyFunction(a)) { - State = { Value = GetStateFromSelection(selection, c => checkFunction((Drawable)c.Item) == a) } + State = { Value = GetStateFromSelection(selection, c => checkFunction(c.Item, a)) } }; }); } @@ -215,15 +229,21 @@ namespace osu.Game.Skinning.Editor drawable.Parent.ToLocalSpace(screenSpacePosition) - drawable.AnchorPosition; } - private void applyOrigin(Anchor anchor) + private void applyOrigins(Anchor origin) { foreach (var item in SelectedItems) { var drawable = (Drawable)item; + if (origin == drawable.Origin) continue; + var previousOrigin = drawable.OriginPosition; - drawable.Origin = anchor; + drawable.Origin = origin; drawable.Position += drawable.OriginPosition - previousOrigin; + + if (item.UsesFixedAnchor) continue; + + applyClosestAnchor(drawable); } } @@ -234,18 +254,86 @@ namespace osu.Game.Skinning.Editor private Quad getSelectionQuad() => GetSurroundingQuad(SelectedBlueprints.SelectMany(b => b.Item.ScreenSpaceDrawQuad.GetVertices().ToArray())); - private void applyAnchor(Anchor anchor) + private void applyFixedAnchors(Anchor anchor) { foreach (var item in SelectedItems) { var drawable = (Drawable)item; - var previousAnchor = drawable.AnchorPosition; - drawable.Anchor = anchor; - drawable.Position -= drawable.AnchorPosition - previousAnchor; + item.UsesFixedAnchor = true; + applyAnchor(drawable, anchor); } } + private void applyClosestAnchors() + { + foreach (var item in SelectedItems) + { + item.UsesFixedAnchor = false; + applyClosestAnchor((Drawable)item); + } + } + + private static Anchor getClosestAnchor(Drawable drawable) + { + var parent = drawable.Parent; + + if (parent == null) + return drawable.Anchor; + + var screenPosition = getScreenPosition(); + + var absolutePosition = parent.ToLocalSpace(screenPosition); + var factor = parent.RelativeToAbsoluteFactor; + + var result = default(Anchor); + + static Anchor getAnchorFromPosition(float xOrY, Anchor anchor0, Anchor anchor1, Anchor anchor2) + { + if (xOrY >= 2 / 3f) + return anchor2; + + if (xOrY >= 1 / 3f) + return anchor1; + + return anchor0; + } + + result |= getAnchorFromPosition(absolutePosition.X / factor.X, Anchor.x0, Anchor.x1, Anchor.x2); + result |= getAnchorFromPosition(absolutePosition.Y / factor.Y, Anchor.y0, Anchor.y1, Anchor.y2); + + return result; + + Vector2 getScreenPosition() + { + var quad = drawable.ScreenSpaceDrawQuad; + var origin = drawable.Origin; + + var pos = quad.TopLeft; + + if (origin.HasFlagFast(Anchor.x2)) + pos.X += quad.Width; + else if (origin.HasFlagFast(Anchor.x1)) + pos.X += quad.Width / 2f; + + if (origin.HasFlagFast(Anchor.y2)) + pos.Y += quad.Height; + else if (origin.HasFlagFast(Anchor.y1)) + pos.Y += quad.Height / 2f; + + return pos; + } + } + + private static void applyAnchor(Drawable drawable, Anchor anchor) + { + if (anchor == drawable.Anchor) return; + + var previousAnchor = drawable.AnchorPosition; + drawable.Anchor = anchor; + drawable.Position -= drawable.AnchorPosition - previousAnchor; + } + private static void adjustScaleFromAnchor(ref Vector2 scale, Anchor reference) { // cancel out scale in axes we don't care about (based on which drag handle was used). diff --git a/osu.Game/Skinning/ISkinnableDrawable.cs b/osu.Game/Skinning/ISkinnableDrawable.cs index d42b6f71b0..60b40982e5 100644 --- a/osu.Game/Skinning/ISkinnableDrawable.cs +++ b/osu.Game/Skinning/ISkinnableDrawable.cs @@ -14,5 +14,12 @@ namespace osu.Game.Skinning /// Whether this component should be editable by an end user. /// bool IsEditable => true; + + /// + /// In the context of the skin layout editor, whether this has a permanent anchor defined. + /// If , this 's is automatically determined by proximity, + /// If , a fixed anchor point has been defined. + /// + bool UsesFixedAnchor { get; set; } } } diff --git a/osu.Game/Skinning/LegacyAccuracyCounter.cs b/osu.Game/Skinning/LegacyAccuracyCounter.cs index 16562d9571..fd5a9500d9 100644 --- a/osu.Game/Skinning/LegacyAccuracyCounter.cs +++ b/osu.Game/Skinning/LegacyAccuracyCounter.cs @@ -12,6 +12,8 @@ namespace osu.Game.Skinning { public class LegacyAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable { + public bool UsesFixedAnchor { get; set; } + public LegacyAccuracyCounter() { Anchor = Anchor.TopRight; diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index 1da80f6613..67280e4acd 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -27,6 +27,8 @@ namespace osu.Game.Skinning private bool isNewStyle; + public bool UsesFixedAnchor { get; set; } + [BackgroundDependencyLoader] private void load(ISkinSource source) { diff --git a/osu.Game/Skinning/LegacyScoreCounter.cs b/osu.Game/Skinning/LegacyScoreCounter.cs index 64ea03d59c..a12defe87e 100644 --- a/osu.Game/Skinning/LegacyScoreCounter.cs +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -13,6 +13,8 @@ namespace osu.Game.Skinning protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; + public bool UsesFixedAnchor { get; set; } + public LegacyScoreCounter() : base(6) { diff --git a/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs b/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs index 2107ca7a8b..67114de948 100644 --- a/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs +++ b/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs @@ -17,6 +17,8 @@ namespace osu.Game.Skinning { public bool IsEditable => false; + public bool UsesFixedAnchor { get; set; } + private readonly Action applyDefaults; ///