mirror of
https://github.com/ppy/osu
synced 2024-12-28 09:52:56 +00:00
322 lines
13 KiB
C#
322 lines
13 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
|
|
{
|
|
/// <summary>
|
|
/// Represents the path of a juice stream.
|
|
/// <para>
|
|
/// A <see cref="JuiceStream"/> holds a legacy <see cref="SliderPath"/> as the representation of the path.
|
|
/// However, the <see cref="SliderPath"/> representation is difficult to work with.
|
|
/// This <see cref="JuiceStreamPath"/> represents the path in a more convenient way, a polyline connecting list of <see cref="JuiceStreamPathVertex"/>s.
|
|
/// </para>
|
|
/// </summary>
|
|
public class JuiceStreamPath
|
|
{
|
|
/// <summary>
|
|
/// The height of legacy osu!standard playfield.
|
|
/// The sliders converted by <see cref="ConvertToSliderPath"/> are vertically contained in this height.
|
|
/// </summary>
|
|
internal const float OSU_PLAYFIELD_HEIGHT = 384;
|
|
|
|
/// <summary>
|
|
/// The list of vertices of the path, which is represented as a polyline connecting the vertices.
|
|
/// </summary>
|
|
public IReadOnlyList<JuiceStreamPathVertex> Vertices => vertices;
|
|
|
|
/// <summary>
|
|
/// The current version number.
|
|
/// This starts from <c>1</c> and incremented whenever this <see cref="JuiceStreamPath"/> is modified.
|
|
/// </summary>
|
|
public int InvalidationID { get; private set; } = 1;
|
|
|
|
/// <summary>
|
|
/// The difference between first vertex's <see cref="JuiceStreamPathVertex.Time"/> and last vertex's <see cref="JuiceStreamPathVertex.Time"/>.
|
|
/// </summary>
|
|
public double Duration => vertices[^1].Time - vertices[0].Time;
|
|
|
|
/// <remarks>
|
|
/// This list should always be non-empty.
|
|
/// </remarks>
|
|
private readonly List<JuiceStreamPathVertex> vertices = new List<JuiceStreamPathVertex>
|
|
{
|
|
new JuiceStreamPathVertex()
|
|
};
|
|
|
|
/// <summary>
|
|
/// Compute the x-position of the path at the given <paramref name="time"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// When the given time is outside of the path, the x position at the corresponding endpoint is returned,
|
|
/// </remarks>
|
|
public float PositionAtTime(double time)
|
|
{
|
|
int index = vertexIndexAtTime(time);
|
|
return positionAtTime(time, index);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove all vertices of this path, then add a new vertex <c>(0, 0)</c>.
|
|
/// </summary>
|
|
public void Clear()
|
|
{
|
|
vertices.Clear();
|
|
vertices.Add(new JuiceStreamPathVertex());
|
|
invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Insert a vertex at given <paramref name="time"/>.
|
|
/// The <see cref="PositionAtTime"/> 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).
|
|
/// </summary>
|
|
/// <returns>The index of the new vertex.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Move the vertex of given <paramref name="index"/> to the given position <paramref name="newX"/>.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Add a new vertex at given <paramref name="time"/> and position.
|
|
/// </summary>
|
|
public void Add(double time, float x)
|
|
{
|
|
int index = InsertVertex(time);
|
|
SetVertexPosition(index, x);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove all vertices that satisfy the given <paramref name="predicate"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// If all vertices are removed, a new vertex <c>(0, 0)</c> is added.
|
|
/// </remarks>
|
|
/// <param name="predicate">The predicate to determine whether a vertex should be removed given the vertex and its index in the path.</param>
|
|
/// <returns>The number of removed vertices.</returns>
|
|
public int RemoveVertices(Func<JuiceStreamPathVertex, int, bool> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recreate this path by using difference set of vertices at given time points.
|
|
/// In addition to the given <paramref name="sampleTimes"/>, the first vertex and the last vertex are always added to the new path.
|
|
/// New vertices use the positions on the original path. Thus, <see cref="PositionAtTime"/>s at <paramref name="sampleTimes"/> are preserved.
|
|
/// </summary>
|
|
public void ResampleVertices(IEnumerable<double> sampleTimes)
|
|
{
|
|
var sampledVertices = new List<JuiceStreamPathVertex>();
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert a <see cref="SliderPath"/> to list of vertices and write the result to this <see cref="JuiceStreamPath"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Duplicated vertices are automatically removed.
|
|
/// </remarks>
|
|
public void ConvertFromSliderPath(SliderPath sliderPath, double velocity)
|
|
{
|
|
var sliderPathVertices = new List<Vector2>();
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes the minimum slider velocity required to convert this path to a <see cref="SliderPath"/>.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert the path of this <see cref="JuiceStreamPath"/> to a <see cref="SliderPath"/> and write the result to <paramref name="sliderPath"/>.
|
|
/// The resulting slider is "folded" to make it vertically contained in the playfield `(0..<see cref="OSU_PLAYFIELD_HEIGHT"/>)` assuming the slider start position is <paramref name="sliderStartY"/>.
|
|
///
|
|
/// The velocity of the converted slider is assumed to be <paramref name="velocity"/>.
|
|
/// To preserve the path, <paramref name="velocity"/> should be at least the value returned by <see cref="ComputeRequiredVelocity"/>.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Find the index at which a new vertex with <paramref name="time"/> can be inserted.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compute the position at the given <paramref name="time"/>, assuming <paramref name="index"/> is the vertex index returned by <see cref="vertexIndexAtTime"/>.
|
|
/// </summary>
|
|
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++;
|
|
}
|
|
}
|