// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Utils; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osuTK; #nullable enable namespace osu.Game.Rulesets.Catch.Objects { /// /// Represents the path of a juice stream. /// /// A holds a legacy as the representation of the path. /// However, the representation is difficult to work with. /// This represents the path in a more convenient way, a polyline connecting list of s. /// /// /// The path can be regarded as a function from the closed interval [Vertices[0].Distance, Vertices[^1].Distance] to the x position, given by . /// To ensure the path is convertible to a , the slope of the function must not be more than 1 everywhere, /// and this slope condition is always maintained as an invariant. /// /// public class JuiceStreamPath { /// /// The height of legacy osu!standard playfield. /// The sliders converted by are vertically contained in this height. /// internal const float OSU_PLAYFIELD_HEIGHT = 384; /// /// The list of vertices of the path, which is represented as a polyline connecting the vertices. /// public IReadOnlyList Vertices => vertices; /// /// The current version number. /// This starts from 1 and incremented whenever this is modified. /// public int InvalidationID { get; private set; } = 1; /// /// The difference between first vertex's and last vertex's . /// public double Distance => vertices[^1].Distance - vertices[0].Distance; /// /// This list should always be non-empty. /// private readonly List vertices = new List { new JuiceStreamPathVertex() }; /// /// Compute the x-position of the path at the given . /// /// /// When the given distance is outside of the path, the x position at the corresponding endpoint is returned, /// public float PositionAtDistance(double distance) { int index = vertexIndexAtDistance(distance); return positionAtDistance(distance, index); } /// /// Remove all vertices of this path, then add a new vertex (0, 0). /// public void Clear() { vertices.Clear(); vertices.Add(new JuiceStreamPathVertex()); invalidate(); } /// /// Insert a vertex at given . /// The is used as the position of the new vertex. /// Thus, the set of points of the path is not changed (up to floating-point precision). /// /// The index of the new vertex. public int InsertVertex(double distance) { if (!double.IsFinite(distance)) throw new ArgumentOutOfRangeException(nameof(distance)); int index = vertexIndexAtDistance(distance); float x = positionAtDistance(distance, index); vertices.Insert(index, new JuiceStreamPathVertex(distance, x)); invalidate(); return index; } /// /// Move the vertex of given to the given position . /// When the distances between vertices are too small for the new vertex positions, the adjacent vertices are moved towards . /// public void SetVertexPosition(int index, float newX) { if (index < 0 || index >= vertices.Count) throw new ArgumentOutOfRangeException(nameof(index)); if (!float.IsFinite(newX)) throw new ArgumentOutOfRangeException(nameof(newX)); var newVertex = new JuiceStreamPathVertex(vertices[index].Distance, newX); for (int i = index - 1; i >= 0 && !canConnect(vertices[i], newVertex); i--) { float clampedX = clampToConnectablePosition(newVertex, vertices[i]); vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX); } for (int i = index + 1; i < vertices.Count; i++) { float clampedX = clampToConnectablePosition(newVertex, vertices[i]); vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX); } vertices[index] = newVertex; invalidate(); } /// /// Add a new vertex at given and position. /// Adjacent vertices are moved when necessary in the same way as . /// public void Add(double distance, float x) { int index = InsertVertex(distance); SetVertexPosition(index, x); } /// /// Remove all vertices that satisfy the given . /// /// /// If all vertices are removed, a new vertex (0, 0) is added. /// /// The predicate to determine whether a vertex should be removed given the vertex and its index in the path. /// The number of removed vertices. public int RemoveVertices(Func predicate) { int index = 0; int removeCount = vertices.RemoveAll(vertex => predicate(vertex, index++)); if (vertices.Count == 0) vertices.Add(new JuiceStreamPathVertex()); if (removeCount != 0) invalidate(); return removeCount; } /// /// Recreate this path by using difference set of vertices at given distances. /// In addition to the given , the first vertex and the last vertex are always added to the new path. /// New vertices use the positions on the original path. Thus, s at are preserved. /// public void ResampleVertices(IEnumerable sampleDistances) { var sampledVertices = new List(); foreach (double distance in sampleDistances) { if (!double.IsFinite(distance)) throw new ArgumentOutOfRangeException(nameof(sampleDistances)); double clampedDistance = Math.Clamp(distance, vertices[0].Distance, vertices[^1].Distance); float x = PositionAtDistance(clampedDistance); sampledVertices.Add(new JuiceStreamPathVertex(clampedDistance, x)); } sampledVertices.Sort(); // The first vertex and the last vertex are always used in the result. vertices.RemoveRange(1, vertices.Count - (vertices.Count == 1 ? 1 : 2)); vertices.InsertRange(1, sampledVertices); invalidate(); } /// /// Convert a to list of vertices and write the result to this . /// /// /// Duplicated vertices are automatically removed. /// public void ConvertFromSliderPath(SliderPath sliderPath) { var sliderPathVertices = new List(); sliderPath.GetPathToProgress(sliderPathVertices, 0, 1); double distance = 0; vertices.Clear(); vertices.Add(new JuiceStreamPathVertex(0, sliderPathVertices.FirstOrDefault().X)); for (int i = 1; i < sliderPathVertices.Count; i++) { distance += Vector2.Distance(sliderPathVertices[i - 1], sliderPathVertices[i]); if (!Precision.AlmostEquals(vertices[^1].Distance, distance)) vertices.Add(new JuiceStreamPathVertex(distance, sliderPathVertices[i].X)); } invalidate(); } /// /// Convert the path of this to a and write the result to . /// The resulting slider is "folded" to make it vertically contained in the playfield `(0..)` assuming the slider start position is . /// public void ConvertToSliderPath(SliderPath sliderPath, float sliderStartY) { const float margin = 1; // Note: these two variables and `sliderPath` are modified by the local functions. double currentDistance = 0; Vector2 lastPosition = new Vector2(vertices[0].X, 0); sliderPath.ControlPoints.Clear(); sliderPath.ControlPoints.Add(new PathControlPoint(lastPosition)); for (int i = 1; i < vertices.Count; i++) { sliderPath.ControlPoints[^1].Type.Value = PathType.Linear; float deltaX = vertices[i].X - lastPosition.X; double length = vertices[i].Distance - currentDistance; // Should satisfy `deltaX^2 + deltaY^2 = length^2`. // By invariants, the expression inside the `sqrt` is (almost) non-negative. double deltaY = Math.Sqrt(Math.Max(0, length * length - (double)deltaX * deltaX)); // When `deltaY` is small, one segment is always enough. // This case is handled separately to prevent divide-by-zero. if (deltaY <= OSU_PLAYFIELD_HEIGHT / 2 - margin) { float nextX = vertices[i].X; float nextY = (float)(lastPosition.Y + getYDirection() * deltaY); addControlPoint(nextX, nextY); continue; } // When `deltaY` is large or when the slider velocity is fast, the segment must be partitioned to subsegments to stay in bounds. for (double currentProgress = 0; currentProgress < deltaY;) { double nextProgress = Math.Min(currentProgress + getMaxDeltaY(), deltaY); float nextX = (float)(vertices[i - 1].X + nextProgress / deltaY * deltaX); float nextY = (float)(lastPosition.Y + getYDirection() * (nextProgress - currentProgress)); addControlPoint(nextX, nextY); currentProgress = nextProgress; } } int getYDirection() { float lastSliderY = sliderStartY + lastPosition.Y; return lastSliderY < OSU_PLAYFIELD_HEIGHT / 2 ? 1 : -1; } float getMaxDeltaY() { float lastSliderY = sliderStartY + lastPosition.Y; return Math.Max(lastSliderY, OSU_PLAYFIELD_HEIGHT - lastSliderY) - margin; } void addControlPoint(float nextX, float nextY) { Vector2 nextPosition = new Vector2(nextX, nextY); sliderPath.ControlPoints.Add(new PathControlPoint(nextPosition)); currentDistance += Vector2.Distance(lastPosition, nextPosition); lastPosition = nextPosition; } } /// /// Find the index at which a new vertex with can be inserted. /// private int vertexIndexAtDistance(double distance) { // The position of `(distance, Infinity)` is uniquely determined because infinite positions are not allowed. int i = vertices.BinarySearch(new JuiceStreamPathVertex(distance, float.PositiveInfinity)); return i < 0 ? ~i : i; } /// /// Compute the position at the given , assuming is the vertex index returned by . /// private float positionAtDistance(double distance, int index) { if (index <= 0) return vertices[0].X; if (index >= vertices.Count) return vertices[^1].X; double length = vertices[index].Distance - vertices[index - 1].Distance; if (Precision.AlmostEquals(length, 0)) return vertices[index].X; float deltaX = vertices[index].X - vertices[index - 1].X; return (float)(vertices[index - 1].X + deltaX * ((distance - vertices[index - 1].Distance) / length)); } /// /// Check the two vertices can connected directly while satisfying the slope condition. /// private bool canConnect(JuiceStreamPathVertex vertex1, JuiceStreamPathVertex vertex2, float allowance = 0) { double xDistance = Math.Abs((double)vertex2.X - vertex1.X); float length = (float)Math.Abs(vertex2.Distance - vertex1.Distance); return xDistance <= length + allowance; } /// /// Move the position of towards the position of /// until the vertex pair satisfies the condition . /// /// The resulting position of . private float clampToConnectablePosition(JuiceStreamPathVertex fixedVertex, JuiceStreamPathVertex movableVertex) { float length = (float)Math.Abs(movableVertex.Distance - fixedVertex.Distance); return Math.Clamp(movableVertex.X, fixedVertex.X - length, fixedVertex.X + length); } private void invalidate() => InvalidationID++; } }