2021-07-19 12:03:53 +08:00
// 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
{
2021-07-19 17:41:29 +08:00
/// <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 ;
2021-07-19 12:03:53 +08:00
/// <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>
2022-05-08 17:32:01 +08:00
/// The difference between first vertex's <see cref="JuiceStreamPathVertex.Time"/> and last vertex's <see cref="JuiceStreamPathVertex.Time"/>.
2021-07-19 12:03:53 +08:00
/// </summary>
2022-05-08 17:32:01 +08:00
public double Duration = > vertices [ ^ 1 ] . Time - vertices [ 0 ] . Time ;
2021-07-19 12:03:53 +08:00
/// <remarks>
/// This list should always be non-empty.
/// </remarks>
private readonly List < JuiceStreamPathVertex > vertices = new List < JuiceStreamPathVertex >
{
new JuiceStreamPathVertex ( )
} ;
/// <summary>
2022-05-08 17:32:01 +08:00
/// Compute the x-position of the path at the given <paramref name="time"/>.
2021-07-19 12:03:53 +08:00
/// </summary>
/// <remarks>
2022-05-08 17:32:01 +08:00
/// When the given time is outside of the path, the x position at the corresponding endpoint is returned,
2021-07-19 12:03:53 +08:00
/// </remarks>
2022-05-08 17:32:01 +08:00
public float PositionAtTime ( double time )
2021-07-19 12:03:53 +08:00
{
2022-05-08 17:32:01 +08:00
int index = vertexIndexAtTime ( time ) ;
return positionAtTime ( time , index ) ;
2021-07-19 12:03:53 +08:00
}
/// <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>
2022-05-08 17:32:01 +08:00
/// Insert a vertex at given <paramref name="time"/>.
/// The <see cref="PositionAtTime"/> is used as the position of the new vertex.
2021-07-19 12:03:53 +08:00
/// 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>
2022-05-08 17:32:01 +08:00
public int InsertVertex ( double time )
2021-07-19 12:03:53 +08:00
{
2022-05-08 17:32:01 +08:00
if ( ! double . IsFinite ( time ) )
throw new ArgumentOutOfRangeException ( nameof ( time ) ) ;
2021-07-19 12:03:53 +08:00
2022-05-08 17:32:01 +08:00
int index = vertexIndexAtTime ( time ) ;
float x = positionAtTime ( time , index ) ;
vertices . Insert ( index , new JuiceStreamPathVertex ( time , x ) ) ;
2021-07-19 12:03:53 +08:00
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 ) ) ;
2022-05-08 17:32:01 +08:00
vertices [ index ] = new JuiceStreamPathVertex ( vertices [ index ] . Time , newX ) ;
2021-07-19 12:03:53 +08:00
invalidate ( ) ;
}
/// <summary>
2022-05-08 17:32:01 +08:00
/// Add a new vertex at given <paramref name="time"/> and position.
2021-07-19 12:03:53 +08:00
/// </summary>
2022-05-08 17:32:01 +08:00
public void Add ( double time , float x )
2021-07-19 12:03:53 +08:00
{
2022-05-08 17:32:01 +08:00
int index = InsertVertex ( time ) ;
2021-07-19 12:03:53 +08:00
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>
2022-05-08 17:32:01 +08:00
/// 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.
2021-07-19 12:03:53 +08:00
/// </summary>
2022-05-08 17:32:01 +08:00
public void ResampleVertices ( IEnumerable < double > sampleTimes )
2021-07-19 12:03:53 +08:00
{
var sampledVertices = new List < JuiceStreamPathVertex > ( ) ;
2022-05-08 17:32:01 +08:00
foreach ( double time in sampleTimes )
2021-07-19 12:03:53 +08:00
{
2022-05-08 17:32:01 +08:00
if ( ! double . IsFinite ( time ) )
throw new ArgumentOutOfRangeException ( nameof ( sampleTimes ) ) ;
2021-07-19 12:03:53 +08:00
2022-05-08 17:32:01 +08:00
double clampedTime = Math . Clamp ( time , vertices [ 0 ] . Time , vertices [ ^ 1 ] . Time ) ;
float x = PositionAtTime ( clampedTime ) ;
sampledVertices . Add ( new JuiceStreamPathVertex ( clampedTime , x ) ) ;
2021-07-19 12:03:53 +08:00
}
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>
2022-05-08 17:32:01 +08:00
public void ConvertFromSliderPath ( SliderPath sliderPath , double velocity )
2021-07-19 12:03:53 +08:00
{
var sliderPathVertices = new List < Vector2 > ( ) ;
sliderPath . GetPathToProgress ( sliderPathVertices , 0 , 1 ) ;
2022-05-08 17:32:01 +08:00
double time = 0 ;
2021-07-19 12:03:53 +08:00
vertices . Clear ( ) ;
vertices . Add ( new JuiceStreamPathVertex ( 0 , sliderPathVertices . FirstOrDefault ( ) . X ) ) ;
for ( int i = 1 ; i < sliderPathVertices . Count ; i + + )
{
2022-05-08 17:32:01 +08:00
time + = Vector2 . Distance ( sliderPathVertices [ i - 1 ] , sliderPathVertices [ i ] ) / velocity ;
2021-07-19 12:03:53 +08:00
2022-05-08 17:32:01 +08:00
if ( ! Precision . AlmostEquals ( vertices [ ^ 1 ] . Time , time ) )
Add ( time , sliderPathVertices [ i ] . X ) ;
2021-07-19 12:03:53 +08:00
}
invalidate ( ) ;
}
2022-05-08 17:32:01 +08:00
/// <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 ;
2022-05-08 20:17:57 +08:00
// 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 ;
2022-05-08 17:32:01 +08:00
maximumSlope = Math . Max ( maximumSlope , xDifference / timeDifference ) ;
}
return maximumSlope ;
}
2021-07-19 12:03:53 +08:00
/// <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"/>.
2022-05-08 17:32:01 +08:00
///
/// 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"/>.
2021-07-19 12:03:53 +08:00
/// </summary>
2022-05-08 17:32:01 +08:00
public void ConvertToSliderPath ( SliderPath sliderPath , float sliderStartY , double velocity )
2021-07-19 12:03:53 +08:00
{
const float margin = 1 ;
// Note: these two variables and `sliderPath` are modified by the local functions.
2022-05-08 17:32:01 +08:00
double currentTime = 0 ;
2021-07-19 12:03:53 +08:00
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 + + )
{
2023-11-08 18:43:54 +08:00
sliderPath . ControlPoints [ ^ 1 ] . Type = PathType . LINEAR ;
2021-07-19 12:03:53 +08:00
float deltaX = vertices [ i ] . X - lastPosition . X ;
2022-05-08 17:32:01 +08:00
double length = ( vertices [ i ] . Time - currentTime ) * velocity ;
2021-07-19 12:03:53 +08:00
// Should satisfy `deltaX^2 + deltaY^2 = length^2`.
2022-05-08 17:32:01 +08:00
// The expression inside the `sqrt` is (almost) non-negative if the slider velocity is large enough.
2021-07-19 12:03:53 +08:00
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 ) ) ;
2022-05-08 17:32:01 +08:00
currentTime + = Vector2 . Distance ( lastPosition , nextPosition ) / velocity ;
2021-07-19 12:03:53 +08:00
lastPosition = nextPosition ;
}
}
/// <summary>
2022-05-08 17:32:01 +08:00
/// Find the index at which a new vertex with <paramref name="time"/> can be inserted.
2021-07-19 12:03:53 +08:00
/// </summary>
2022-05-08 17:32:01 +08:00
private int vertexIndexAtTime ( double time )
2021-07-19 12:03:53 +08:00
{
2022-05-08 17:32:01 +08:00
// The position of `(time, Infinity)` is uniquely determined because infinite positions are not allowed.
int i = vertices . BinarySearch ( new JuiceStreamPathVertex ( time , float . PositiveInfinity ) ) ;
2021-07-19 12:03:53 +08:00
return i < 0 ? ~ i : i ;
}
/// <summary>
2022-05-08 17:32:01 +08:00
/// Compute the position at the given <paramref name="time"/>, assuming <paramref name="index"/> is the vertex index returned by <see cref="vertexIndexAtTime"/>.
2021-07-19 12:03:53 +08:00
/// </summary>
2022-05-08 17:32:01 +08:00
private float positionAtTime ( double time , int index )
2021-07-19 12:03:53 +08:00
{
if ( index < = 0 )
return vertices [ 0 ] . X ;
if ( index > = vertices . Count )
return vertices [ ^ 1 ] . X ;
2022-05-08 17:32:01 +08:00
double duration = vertices [ index ] . Time - vertices [ index - 1 ] . Time ;
if ( Precision . AlmostEquals ( duration , 0 ) )
2021-07-19 12:03:53 +08:00
return vertices [ index ] . X ;
float deltaX = vertices [ index ] . X - vertices [ index - 1 ] . X ;
2022-05-08 17:32:01 +08:00
return ( float ) ( vertices [ index - 1 ] . X + deltaX * ( ( time - vertices [ index - 1 ] . Time ) / duration ) ) ;
2021-07-19 12:03:53 +08:00
}
private void invalidate ( ) = > InvalidationID + + ;
}
}