// 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; 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. /// /// 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 Duration => vertices[^1].Time - vertices[0].Time; /// /// 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 time is outside of the path, the x position at the corresponding endpoint is returned, /// public float PositionAtTime(double time) { int index = vertexIndexAtTime(time); return positionAtTime(time, 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 time) { if (!double.IsFinite(time)) throw new ArgumentOutOfRangeException(nameof(time)); int index = vertexIndexAtTime(time); float x = positionAtTime(time, index); vertices.Insert(index, new JuiceStreamPathVertex(time, x)); invalidate(); return index; } /// /// Move the vertex of given to the given position . /// 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)); vertices[index] = new JuiceStreamPathVertex(vertices[index].Time, newX); invalidate(); } /// /// Add a new vertex at given and position. /// public void Add(double time, float x) { int index = InsertVertex(time); 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 time points. /// 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 sampleTimes) { var sampledVertices = new List(); foreach (double time in sampleTimes) { if (!double.IsFinite(time)) throw new ArgumentOutOfRangeException(nameof(sampleTimes)); double clampedTime = Math.Clamp(time, vertices[0].Time, vertices[^1].Time); float x = PositionAtTime(clampedTime); sampledVertices.Add(new JuiceStreamPathVertex(clampedTime, 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, double velocity) { var sliderPathVertices = new List(); sliderPath.GetPathToProgress(sliderPathVertices, 0, 1); double time = 0; vertices.Clear(); vertices.Add(new JuiceStreamPathVertex(0, sliderPathVertices.FirstOrDefault().X)); for (int i = 1; i < sliderPathVertices.Count; i++) { time += Vector2.Distance(sliderPathVertices[i - 1], sliderPathVertices[i]) / velocity; if (!Precision.AlmostEquals(vertices[^1].Time, time)) Add(time, sliderPathVertices[i].X); } invalidate(); } /// /// Computes the minimum slider velocity required to convert this path to a . /// public double ComputeRequiredVelocity() { double maximumSlope = 0; for (int i = 1; i < vertices.Count; i++) { double xDifference = Math.Abs((double)vertices[i].X - vertices[i - 1].X); double timeDifference = vertices[i].Time - vertices[i - 1].Time; // A short segment won't affect the resulting path much anyways so ignore it to avoid divide-by-zero. if (Precision.AlmostEquals(timeDifference, 0)) continue; maximumSlope = Math.Max(maximumSlope, xDifference / timeDifference); } return maximumSlope; } /// /// 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 . /// /// The velocity of the converted slider is assumed to be . /// To preserve the path, should be at least the value returned by . /// public void ConvertToSliderPath(SliderPath sliderPath, float sliderStartY, double velocity) { const float margin = 1; // Note: these two variables and `sliderPath` are modified by the local functions. double currentTime = 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 = PathType.LINEAR; float deltaX = vertices[i].X - lastPosition.X; double length = (vertices[i].Time - currentTime) * velocity; // Should satisfy `deltaX^2 + deltaY^2 = length^2`. // The expression inside the `sqrt` is (almost) non-negative if the slider velocity is large enough. 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)); currentTime += Vector2.Distance(lastPosition, nextPosition) / velocity; lastPosition = nextPosition; } } /// /// Find the index at which a new vertex with can be inserted. /// private int vertexIndexAtTime(double time) { // The position of `(time, Infinity)` is uniquely determined because infinite positions are not allowed. int i = vertices.BinarySearch(new JuiceStreamPathVertex(time, float.PositiveInfinity)); return i < 0 ? ~i : i; } /// /// Compute the position at the given , assuming is the vertex index returned by . /// private float positionAtTime(double time, int index) { if (index <= 0) return vertices[0].X; if (index >= vertices.Count) return vertices[^1].X; double duration = vertices[index].Time - vertices[index - 1].Time; if (Precision.AlmostEquals(duration, 0)) return vertices[index].X; float deltaX = vertices[index].X - vertices[index - 1].X; return (float)(vertices[index - 1].X + deltaX * ((time - vertices[index - 1].Time) / duration)); } private void invalidate() => InvalidationID++; } }