diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath2.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath2.cs new file mode 100644 index 0000000000..08d54fcdda --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath2.cs @@ -0,0 +1,159 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Lines; +using osu.Framework.MathUtils; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSliderPath2 : OsuTestScene + { + private readonly SmoothPath drawablePath; + private SliderPath2 path; + + public TestSceneSliderPath2() + { + Child = drawablePath = new SmoothPath + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + } + + [SetUp] + public void Setup() => Schedule(() => + { + path = new SliderPath2(); + }); + + protected override void Update() + { + base.Update(); + + if (path != null) + { + List vertices = new List(); + path.GetPathToProgress(vertices, 0, 1); + + drawablePath.Vertices = vertices; + } + } + + [Test] + public void TestEmptyPath() + { + } + + [TestCase(PathType.Linear)] + [TestCase(PathType.Bezier)] + [TestCase(PathType.Catmull)] + [TestCase(PathType.PerfectCurve)] + public void TestSingleSegment(PathType type) + => AddStep("create path", () => path.ControlPoints.AddRange(createSegment(type, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + + [TestCase(PathType.Linear)] + [TestCase(PathType.Bezier)] + [TestCase(PathType.Catmull)] + [TestCase(PathType.PerfectCurve)] + public void TestMultipleSegment(PathType type) + { + AddStep("create path", () => + { + path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero)); + path.ControlPoints.AddRange(createSegment(type, new Vector2(0, 100), new Vector2(100), Vector2.Zero)); + }); + } + + [Test] + public void TestAddControlPoint() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100)))); + AddStep("add point", () => path.ControlPoints.Add(new PathControlPoint { Position = { Value = new Vector2(100) } })); + } + + [Test] + public void TestInsertControlPoint() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(100)))); + AddStep("insert point", () => path.ControlPoints.Insert(1, new PathControlPoint { Position = { Value = new Vector2(0, 100) } })); + } + + [Test] + public void TestRemoveControlPoint() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + AddStep("remove second point", () => path.ControlPoints.RemoveAt(1)); + } + + [Test] + public void TestChangePathType() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + AddStep("change type to bezier", () => path.ControlPoints[0].Type.Value = PathType.Bezier); + } + + [Test] + public void TestAddSegmentByChangingType() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0)))); + AddStep("change second point type to bezier", () => path.ControlPoints[1].Type.Value = PathType.Bezier); + } + + [Test] + public void TestRemoveSegmentByChangingType() + { + AddStep("create path", () => + { + path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0))); + path.ControlPoints[1].Type.Value = PathType.Bezier; + }); + + AddStep("change second point type to null", () => path.ControlPoints[1].Type.Value = null); + } + + [Test] + public void TestRemoveSegmentByRemovingControlPoint() + { + AddStep("create path", () => + { + path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0))); + path.ControlPoints[1].Type.Value = PathType.Bezier; + }); + + AddStep("remove second point", () => path.ControlPoints.RemoveAt(1)); + } + + [TestCase(2)] + [TestCase(4)] + public void TestPerfectCurveFallbackScenarios(int points) + { + AddStep("create path", () => + { + switch (points) + { + case 2: + path.ControlPoints.AddRange(createSegment(PathType.PerfectCurve, Vector2.Zero, new Vector2(0, 100))); + break; + case 4: + path.ControlPoints.AddRange(createSegment(PathType.PerfectCurve, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0))); + break; + } + }); + + } + + private List createSegment(PathType type, params Vector2[] controlPoints) + { + var points = controlPoints.Select(p => new PathControlPoint { Position = { Value = p } }).ToList(); + points[0].Type.Value = type; + return points; + } + } +} diff --git a/osu.Game/Rulesets/Objects/SliderPath2.cs b/osu.Game/Rulesets/Objects/SliderPath2.cs new file mode 100644 index 0000000000..560313a4d5 --- /dev/null +++ b/osu.Game/Rulesets/Objects/SliderPath2.cs @@ -0,0 +1,274 @@ +// 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 Newtonsoft.Json; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.MathUtils; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.Objects +{ + public class SliderPath2 + { + /// + /// The user-set distance of the path. If non-null, will match this value, + /// and the path will be shortened/lengthened to match this length. + /// + public readonly double? ExpectedDistance; + + /// + /// The control points of the path. + /// + public readonly BindableList ControlPoints = new BindableList(); + + private readonly Cached pathCache = new Cached(); + + private readonly List calculatedPath = new List(); + private readonly List cumulativeLength = new List(); + + /// + /// Creates a new . + /// + /// A user-set distance of the path that may be shorter or longer than the true distance between all control points. + /// The path will be shortened/lengthened to match this length. If null, the path will use the true distance between all control points. + [JsonConstructor] + public SliderPath2(double? expectedDistance = null) + { + ExpectedDistance = expectedDistance; + + ControlPoints.ItemsAdded += items => + { + foreach (var c in items) + c.Changed += onControlPointChanged; + + onControlPointChanged(); + }; + + ControlPoints.ItemsRemoved += items => + { + foreach (var c in items) + c.Changed -= onControlPointChanged; + + onControlPointChanged(); + }; + + void onControlPointChanged() => pathCache.Invalidate(); + } + + /// + /// The distance of the path after lengthening/shortening to account for . + /// + [JsonIgnore] + public double Distance + { + get + { + ensureValid(); + return cumulativeLength.Count == 0 ? 0 : cumulativeLength[cumulativeLength.Count - 1]; + } + } + + /// + /// Computes the slider path until a given progress that ranges from 0 (beginning of the slider) + /// to 1 (end of the slider) and stores the generated path in the given list. + /// + /// The list to be filled with the computed path. + /// Start progress. Ranges from 0 (beginning of the slider) to 1 (end of the slider). + /// End progress. Ranges from 0 (beginning of the slider) to 1 (end of the slider). + public void GetPathToProgress(List path, double p0, double p1) + { + ensureValid(); + + double d0 = progressToDistance(p0); + double d1 = progressToDistance(p1); + + path.Clear(); + + int i = 0; + + for (; i < calculatedPath.Count && cumulativeLength[i] < d0; ++i) + { + } + + path.Add(interpolateVertices(i, d0)); + + for (; i < calculatedPath.Count && cumulativeLength[i] <= d1; ++i) + path.Add(calculatedPath[i]); + + path.Add(interpolateVertices(i, d1)); + } + + /// + /// Computes the position on the slider at a given progress that ranges from 0 (beginning of the path) + /// to 1 (end of the path). + /// + /// Ranges from 0 (beginning of the path) to 1 (end of the path). + /// + public Vector2 PositionAt(double progress) + { + ensureValid(); + + double d = progressToDistance(progress); + return interpolateVertices(indexOfDistance(d), d); + } + + private void ensureValid() + { + if (pathCache.IsValid) + return; + + calculatePath(); + calculateCumulativeLength(); + + pathCache.Validate(); + } + + private void calculatePath() + { + calculatedPath.Clear(); + + if (ControlPoints.Count == 0) + return; + + if (ControlPoints[0].Type.Value == null) + throw new InvalidOperationException($"The first control point in a {nameof(SliderPath2)} must have a non-null type."); + + Vector2[] vertices = new Vector2[ControlPoints.Count]; + for (int i = 0; i < ControlPoints.Count; i++) + vertices[i] = ControlPoints[i].Position.Value; + + int start = 0; + + for (int i = 0; i < ControlPoints.Count; i++) + { + if (ControlPoints[i].Type.Value == null && i < ControlPoints.Count - 1) + continue; + + Debug.Assert(ControlPoints[start].Type.Value.HasValue); + + // The current vertex ends the segment + var segmentVertices = vertices.AsSpan().Slice(start, i - start + 1); + var segmentType = ControlPoints[start].Type.Value.Value; + + foreach (Vector2 t in computeSubPath(segmentVertices, segmentType)) + { + if (calculatedPath.Count == 0 || calculatedPath.Last() != t) + calculatedPath.Add(t); + } + + // Start the new segment at the current vertex + start = i; + } + + static List computeSubPath(ReadOnlySpan subControlPoints, PathType type) + { + switch (type) + { + case PathType.Linear: + return PathApproximator.ApproximateLinear(subControlPoints); + + case PathType.PerfectCurve: + if (subControlPoints.Length != 3) + break; + + List subpath = PathApproximator.ApproximateCircularArc(subControlPoints); + + // If for some reason a circular arc could not be fit to the 3 given points, fall back to a numerically stable bezier approximation. + if (subpath.Count == 0) + break; + + return subpath; + + case PathType.Catmull: + return PathApproximator.ApproximateCatmull(subControlPoints); + } + + return PathApproximator.ApproximateBezier(subControlPoints); + } + } + + private void calculateCumulativeLength() + { + double l = 0; + + cumulativeLength.Clear(); + cumulativeLength.Add(l); + + for (int i = 0; i < calculatedPath.Count - 1; ++i) + { + Vector2 diff = calculatedPath[i + 1] - calculatedPath[i]; + double d = diff.Length; + + // Shorted slider paths that are too long compared to the expected distance + if (ExpectedDistance.HasValue && ExpectedDistance - l < d) + { + calculatedPath[i + 1] = calculatedPath[i] + diff * (float)((ExpectedDistance - l) / d); + calculatedPath.RemoveRange(i + 2, calculatedPath.Count - 2 - i); + + l = ExpectedDistance.Value; + cumulativeLength.Add(l); + break; + } + + l += d; + cumulativeLength.Add(l); + } + + // Lengthen slider paths that are too short compared to the expected distance + if (ExpectedDistance.HasValue && l < ExpectedDistance && calculatedPath.Count > 1) + { + Vector2 diff = calculatedPath[calculatedPath.Count - 1] - calculatedPath[calculatedPath.Count - 2]; + double d = diff.Length; + + if (d <= 0) + return; + + calculatedPath[calculatedPath.Count - 1] += diff * (float)((ExpectedDistance - l) / d); + cumulativeLength[calculatedPath.Count - 1] = ExpectedDistance.Value; + } + } + + private int indexOfDistance(double d) + { + int i = cumulativeLength.BinarySearch(d); + if (i < 0) i = ~i; + + return i; + } + + private double progressToDistance(double progress) + { + return Math.Clamp(progress, 0, 1) * Distance; + } + + private Vector2 interpolateVertices(int i, double d) + { + if (calculatedPath.Count == 0) + return Vector2.Zero; + + if (i <= 0) + return calculatedPath.First(); + if (i >= calculatedPath.Count) + return calculatedPath.Last(); + + Vector2 p0 = calculatedPath[i - 1]; + Vector2 p1 = calculatedPath[i]; + + double d0 = cumulativeLength[i - 1]; + double d1 = cumulativeLength[i]; + + // Avoid division by and almost-zero number in case two points are extremely close to each other. + if (Precision.AlmostEquals(d0, d1)) + return p0; + + double w = (d - d0) / (d1 - d0); + return p0 + (p1 - p0) * (float)w; + } + } +}