// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; using osu.Game.Tests.Visual; using osuTK; namespace osu.Game.Tests.Editing { [HeadlessTest] public partial class TestSceneHitObjectComposerDistanceSnapping : EditorClockTestScene { private TestHitObjectComposer composer = null!; [Cached(typeof(EditorBeatmap))] [Cached(typeof(IBeatSnapProvider))] private readonly EditorBeatmap editorBeatmap; protected override Container Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both }; public TestSceneHitObjectComposerDistanceSnapping() { base.Content.Add(new Container { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { editorBeatmap = new EditorBeatmap(new OsuBeatmap { BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, }), Content }, }); } [SetUp] public void Setup() => Schedule(() => { Child = composer = new TestHitObjectComposer(); BeatDivisor.Value = 1; composer.EditorBeatmap.Difficulty.SliderMultiplier = 1; composer.EditorBeatmap.ControlPointInfo.Clear(); composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 }); }); [TestCase(1)] [TestCase(2)] public void TestSliderMultiplier(float multiplier) { AddStep($"set slider multiplier = {multiplier}", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = multiplier); assertSnapDistance(100 * multiplier, null, true); } [TestCase(1)] [TestCase(2)] public void TestSpeedMultiplierDoesNotChangeDistanceSnap(float multiplier) { assertSnapDistance(100, new Slider { SliderVelocityMultiplier = multiplier }, false); } [TestCase(1)] [TestCase(2)] public void TestSpeedMultiplierDoesChangeDistanceSnap(float multiplier) { assertSnapDistance(100 * multiplier, new Slider { SliderVelocityMultiplier = multiplier }, true); } [TestCase(1)] [TestCase(2)] public void TestBeatDivisor(int divisor) { AddStep($"set divisor = {divisor}", () => BeatDivisor.Value = divisor); assertSnapDistance(100f / divisor, null, true); } /// /// The basic distance-duration functions should always include slider velocity of the reference object. /// [Test] public void TestConversionsWithSliderVelocity() { const float base_distance = 100; const float slider_velocity = 1.2f; var referenceObject = new Slider { SliderVelocityMultiplier = slider_velocity }; AddStep("add to beatmap", () => composer.EditorBeatmap.Add(referenceObject)); assertSnapDistance(base_distance * slider_velocity, referenceObject, true); assertSnappedDistance(base_distance * slider_velocity + 10, base_distance * slider_velocity, referenceObject); assertSnappedDuration(base_distance * slider_velocity + 10, 1000, referenceObject); assertDistanceToDuration(base_distance * slider_velocity, 1000, referenceObject); assertDurationToDistance(1000, base_distance * slider_velocity, referenceObject); } [Test] public void TestConvertDurationToDistance() { assertDurationToDistance(500, 50); assertDurationToDistance(1000, 100); AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = 2); assertDurationToDistance(500, 100); assertDurationToDistance(1000, 200); AddStep("set beat length = 500", () => { composer.EditorBeatmap.ControlPointInfo.Clear(); composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); }); assertDurationToDistance(500, 200); assertDurationToDistance(1000, 400); } [Test] public void TestConvertDistanceToDuration() { assertDistanceToDuration(50, 500); assertDistanceToDuration(100, 1000); AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = 2); assertDistanceToDuration(100, 500); assertDistanceToDuration(200, 1000); AddStep("set beat length = 500", () => { composer.EditorBeatmap.ControlPointInfo.Clear(); composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); }); assertDistanceToDuration(200, 500); assertDistanceToDuration(400, 1000); } [Test] public void TestGetSnappedDurationFromDistance() { assertSnappedDuration(0, 0); assertSnappedDuration(50, 1000); assertSnappedDuration(100, 1000); assertSnappedDuration(150, 2000); assertSnappedDuration(200, 2000); assertSnappedDuration(250, 3000); AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = 2); assertSnappedDuration(0, 0); assertSnappedDuration(50, 0); assertSnappedDuration(100, 1000); assertSnappedDuration(150, 1000); assertSnappedDuration(200, 1000); assertSnappedDuration(250, 1000); AddStep("set beat length = 500", () => { composer.EditorBeatmap.ControlPointInfo.Clear(); composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); }); assertSnappedDuration(50, 0); assertSnappedDuration(100, 500); assertSnappedDuration(150, 500); assertSnappedDuration(200, 500); assertSnappedDuration(250, 500); assertSnappedDuration(400, 1000); } [Test] public void GetSnappedDistanceFromDistance() { assertSnappedDistance(50, 0); assertSnappedDistance(100, 100); assertSnappedDistance(150, 100); assertSnappedDistance(200, 200); assertSnappedDistance(250, 200); AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = 2); assertSnappedDistance(50, 0); assertSnappedDistance(100, 0); assertSnappedDistance(150, 0); assertSnappedDistance(200, 200); assertSnappedDistance(250, 200); AddStep("set beat length = 500", () => { composer.EditorBeatmap.ControlPointInfo.Clear(); composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); }); assertSnappedDistance(50, 0); assertSnappedDistance(100, 0); assertSnappedDistance(150, 0); assertSnappedDistance(200, 200); assertSnappedDistance(250, 200); assertSnappedDistance(400, 400); } [Test] public void TestUnsnappedObject() { var slider = new Slider { StartTime = 0, Path = new SliderPath { ControlPoints = { new PathControlPoint(), // simulate object snapped to 1/3rds // this object's end time will be 2000 / 3 = 666.66... ms new PathControlPoint(new Vector2(200 / 3f, 0)), } } }; AddStep("add slider", () => composer.EditorBeatmap.Add(slider)); AddStep("set snap to 1/4", () => BeatDivisor.Value = 4); // with default beat length of 1000ms and snap at 1/4, the valid snap times are 500ms, 750ms, and 1000ms // with default settings, the snapped distance will be a tenth of the difference of the time delta // (500 - 666.66...) / 10 = -16.66... = -100 / 6 assertSnappedDistance(0, -100 / 6f, slider); assertSnappedDistance(7, -100 / 6f, slider); // (750 - 666.66...) / 10 = 8.33... = 100 / 12 assertSnappedDistance(9, 100 / 12f, slider); assertSnappedDistance(33, 100 / 12f, slider); // (1000 - 666.66...) / 10 = 33.33... = 100 / 3 assertSnappedDistance(34, 100 / 3f, slider); } [Test] public void TestUseCurrentSnap() { AddStep("add objects to beatmap", () => { editorBeatmap.Add(new HitCircle { StartTime = 1000 }); editorBeatmap.Add(new HitCircle { Position = new Vector2(100), StartTime = 2000 }); }); AddStep("hover use current snap button", () => InputManager.MoveMouseTo(composer.ChildrenOfType().Single())); AddUntilStep("use current snap expanded", () => composer.ChildrenOfType().Single().Expanded.Value, () => Is.True); AddStep("seek before first object", () => EditorClock.Seek(0)); AddUntilStep("use current snap not available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.False); AddStep("seek to between objects", () => EditorClock.Seek(1500)); AddUntilStep("use current snap available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.True); AddStep("seek after last object", () => EditorClock.Seek(2500)); AddUntilStep("use current snap not available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.False); } private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity) => AddAssert($"distance is {expectedDistance}", () => composer.DistanceSnapProvider.GetBeatSnapDistanceAt(referenceObject ?? new HitObject(), includeSliderVelocity), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private void assertDurationToDistance(double duration, float expectedDistance, HitObject? referenceObject = null) => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DistanceSnapProvider.DurationToDistance(referenceObject ?? new HitObject(), duration), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private void assertDistanceToDuration(float distance, double expectedDuration, HitObject? referenceObject = null) => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceSnapProvider.DistanceToDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); private void assertSnappedDuration(float distance, double expectedDuration, HitObject? referenceObject = null) => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); private void assertSnappedDistance(float distance, float expectedDistance, HitObject? referenceObject = null) => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance, DistanceSnapTarget.End), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private partial class TestHitObjectComposer : OsuHitObjectComposer { public new EditorBeatmap EditorBeatmap => base.EditorBeatmap; public new IDistanceSnapProvider DistanceSnapProvider => base.DistanceSnapProvider; public TestHitObjectComposer() : base(new OsuRuleset()) { } } } }