2022-03-09 12:36:31 +00: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.Graphics.Primitives ;
using osu.Game.Rulesets.Osu.Objects ;
using osu.Game.Rulesets.Osu.UI ;
using osuTK ;
#nullable enable
2022-03-09 13:52:15 +00:00
namespace osu.Game.Rulesets.Osu.Utils
2022-03-09 12:36:31 +00:00
{
/// <summary>
2022-03-09 12:52:11 +00:00
/// Places hit objects according to information in <see cref="ObjectPositionInfos"/> while keeping objects inside the playfield.
2022-03-09 12:36:31 +00:00
/// </summary>
public class OsuHitObjectPositionModifier
{
/// <summary>
/// Number of previous hitobjects to be shifted together when an object is being moved.
/// </summary>
private const int preceding_hitobjects_to_shift = 10 ;
private static readonly Vector2 playfield_centre = OsuPlayfield . BASE_SIZE / 2 ;
private readonly List < OsuHitObject > hitObjects ;
2022-03-09 12:52:11 +00:00
private readonly List < ObjectPositionInfo > objectPositionInfos = new List < ObjectPositionInfo > ( ) ;
2022-03-09 12:36:31 +00:00
/// <summary>
/// Contains information specifying how each hit object should be placed.
/// <para>The default values correspond to how objects are originally placed in the beatmap.</para>
/// </summary>
2022-03-09 12:52:11 +00:00
public IReadOnlyList < IObjectPositionInfo > ObjectPositionInfos = > objectPositionInfos ;
2022-03-09 12:36:31 +00:00
public OsuHitObjectPositionModifier ( List < OsuHitObject > hitObjects )
{
this . hitObjects = hitObjects ;
2022-03-09 12:52:11 +00:00
populateObjectPositionInfos ( ) ;
2022-03-09 12:36:31 +00:00
}
2022-03-09 12:52:11 +00:00
private void populateObjectPositionInfos ( )
2022-03-09 12:36:31 +00:00
{
Vector2 previousPosition = playfield_centre ;
float previousAngle = 0 ;
foreach ( OsuHitObject hitObject in hitObjects )
{
Vector2 relativePosition = hitObject . Position - previousPosition ;
float absoluteAngle = ( float ) Math . Atan2 ( relativePosition . Y , relativePosition . X ) ;
float relativeAngle = absoluteAngle - previousAngle ;
2022-03-09 12:52:11 +00:00
objectPositionInfos . Add ( new ObjectPositionInfo ( hitObject )
2022-03-09 12:36:31 +00:00
{
RelativeAngle = relativeAngle ,
DistanceFromPrevious = relativePosition . Length
} ) ;
previousPosition = hitObject . EndPosition ;
previousAngle = absoluteAngle ;
}
}
/// <summary>
2022-03-09 12:52:11 +00:00
/// Reposition the hit objects according to the information in <see cref="ObjectPositionInfos"/>.
2022-03-09 12:36:31 +00:00
/// </summary>
2022-03-09 12:52:11 +00:00
public void ApplyModifications ( )
2022-03-09 12:36:31 +00:00
{
2022-03-09 12:52:11 +00:00
ObjectPositionInfo ? previous = null ;
2022-03-09 12:36:31 +00:00
2022-03-10 03:23:52 +00:00
for ( int i = 0 ; i < objectPositionInfos . Count ; i + + )
2022-03-09 12:36:31 +00:00
{
2022-03-09 12:52:11 +00:00
var current = objectPositionInfos [ i ] ;
2022-03-10 03:23:52 +00:00
var hitObject = current . HitObject ;
2022-03-09 12:36:31 +00:00
if ( hitObject is Spinner )
{
previous = null ;
continue ;
}
2022-03-09 12:52:11 +00:00
computeModifiedPosition ( current , previous , i > 1 ? objectPositionInfos [ i - 2 ] : null ) ;
2022-03-09 12:36:31 +00:00
// Move hit objects back into the playfield if they are outside of it
Vector2 shift = Vector2 . Zero ;
switch ( hitObject )
{
case HitCircle circle :
shift = clampHitCircleToPlayfield ( circle , current ) ;
break ;
case Slider slider :
shift = clampSliderToPlayfield ( slider , current ) ;
break ;
}
if ( shift ! = Vector2 . Zero )
{
var toBeShifted = new List < OsuHitObject > ( ) ;
for ( int j = i - 1 ; j > = i - preceding_hitobjects_to_shift & & j > = 0 ; j - - )
{
// only shift hit circles
if ( ! ( hitObjects [ j ] is HitCircle ) ) break ;
toBeShifted . Add ( hitObjects [ j ] ) ;
}
if ( toBeShifted . Count > 0 )
applyDecreasingShift ( toBeShifted , shift ) ;
}
previous = current ;
}
}
/// <summary>
2022-03-09 12:52:11 +00:00
/// Compute the modified position of a hit object while attempting to keep it inside the playfield.
2022-03-09 12:36:31 +00:00
/// </summary>
2022-03-09 12:52:11 +00:00
/// <param name="current">The <see cref="ObjectPositionInfo"/> representing the hit object to have the modified position computed for.</param>
/// <param name="previous">The <see cref="ObjectPositionInfo"/> representing the hit object immediately preceding the current one.</param>
/// <param name="beforePrevious">The <see cref="ObjectPositionInfo"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
private void computeModifiedPosition ( ObjectPositionInfo current , ObjectPositionInfo ? previous , ObjectPositionInfo ? beforePrevious )
2022-03-09 12:36:31 +00:00
{
float previousAbsoluteAngle = 0f ;
if ( previous ! = null )
{
Vector2 earliestPosition = beforePrevious ? . HitObject . EndPosition ? ? playfield_centre ;
Vector2 relativePosition = previous . HitObject . Position - earliestPosition ;
previousAbsoluteAngle = ( float ) Math . Atan2 ( relativePosition . Y , relativePosition . X ) ;
}
float absoluteAngle = previousAbsoluteAngle + current . RelativeAngle ;
var posRelativeToPrev = new Vector2 (
current . DistanceFromPrevious * ( float ) Math . Cos ( absoluteAngle ) ,
current . DistanceFromPrevious * ( float ) Math . Sin ( absoluteAngle )
) ;
2022-03-09 12:52:11 +00:00
Vector2 lastEndPosition = previous ? . EndPositionModified ? ? playfield_centre ;
2022-03-09 12:36:31 +00:00
posRelativeToPrev = OsuHitObjectGenerationUtils . RotateAwayFromEdge ( lastEndPosition , posRelativeToPrev ) ;
2022-03-09 12:52:11 +00:00
current . PositionModified = lastEndPosition + posRelativeToPrev ;
2022-03-09 12:36:31 +00:00
}
/// <summary>
2022-03-09 12:52:11 +00:00
/// Move the modified position of a hit circle so that it fits inside the playfield.
2022-03-09 12:36:31 +00:00
/// </summary>
2022-03-09 12:52:11 +00:00
/// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
private Vector2 clampHitCircleToPlayfield ( HitCircle circle , ObjectPositionInfo objectPositionInfo )
2022-03-09 12:36:31 +00:00
{
2022-03-09 12:52:11 +00:00
var previousPosition = objectPositionInfo . PositionModified ;
objectPositionInfo . EndPositionModified = objectPositionInfo . PositionModified = clampToPlayfieldWithPadding (
objectPositionInfo . PositionModified ,
2022-03-09 12:36:31 +00:00
( float ) circle . Radius
) ;
2022-03-09 12:52:11 +00:00
circle . Position = objectPositionInfo . PositionModified ;
2022-03-09 12:36:31 +00:00
2022-03-09 12:52:11 +00:00
return objectPositionInfo . PositionModified - previousPosition ;
2022-03-09 12:36:31 +00:00
}
/// <summary>
/// Moves the <see cref="Slider"/> and all necessary nested <see cref="OsuHitObject"/>s into the <see cref="OsuPlayfield"/> if they aren't already.
/// </summary>
2022-03-09 12:52:11 +00:00
/// <returns>The deviation from the original modified position in order to fit within the playfield.</returns>
private Vector2 clampSliderToPlayfield ( Slider slider , ObjectPositionInfo objectPositionInfo )
2022-03-09 12:36:31 +00:00
{
var possibleMovementBounds = calculatePossibleMovementBounds ( slider ) ;
2022-03-09 12:52:11 +00:00
var previousPosition = objectPositionInfo . PositionModified ;
2022-03-09 12:36:31 +00:00
// Clamp slider position to the placement area
// If the slider is larger than the playfield, force it to stay at the original position
float newX = possibleMovementBounds . Width < 0
2022-03-09 12:52:11 +00:00
? objectPositionInfo . PositionOriginal . X
2022-03-09 12:36:31 +00:00
: Math . Clamp ( previousPosition . X , possibleMovementBounds . Left , possibleMovementBounds . Right ) ;
float newY = possibleMovementBounds . Height < 0
2022-03-09 12:52:11 +00:00
? objectPositionInfo . PositionOriginal . Y
2022-03-09 12:36:31 +00:00
: Math . Clamp ( previousPosition . Y , possibleMovementBounds . Top , possibleMovementBounds . Bottom ) ;
2022-03-09 12:52:11 +00:00
slider . Position = objectPositionInfo . PositionModified = new Vector2 ( newX , newY ) ;
objectPositionInfo . EndPositionModified = slider . EndPosition ;
2022-03-09 12:36:31 +00:00
2022-03-09 12:52:11 +00:00
shiftNestedObjects ( slider , objectPositionInfo . PositionModified - objectPositionInfo . PositionOriginal ) ;
2022-03-09 12:36:31 +00:00
2022-03-09 12:52:11 +00:00
return objectPositionInfo . PositionModified - previousPosition ;
2022-03-09 12:36:31 +00:00
}
/// <summary>
/// Decreasingly shift a list of <see cref="OsuHitObject"/>s by a specified amount.
/// The first item in the list is shifted by the largest amount, while the last item is shifted by the smallest amount.
/// </summary>
/// <param name="hitObjects">The list of hit objects to be shifted.</param>
/// <param name="shift">The amount to be shifted.</param>
private void applyDecreasingShift ( IList < OsuHitObject > hitObjects , Vector2 shift )
{
for ( int i = 0 ; i < hitObjects . Count ; i + + )
{
var hitObject = hitObjects [ i ] ;
// The first object is shifted by a vector slightly smaller than shift
// The last object is shifted by a vector slightly larger than zero
Vector2 position = hitObject . Position + shift * ( ( hitObjects . Count - i ) / ( float ) ( hitObjects . Count + 1 ) ) ;
hitObject . Position = clampToPlayfieldWithPadding ( position , ( float ) hitObject . Radius ) ;
}
}
/// <summary>
/// Calculates a <see cref="RectangleF"/> which contains all of the possible movements of the slider (in relative X/Y coordinates)
/// such that the entire slider is inside the playfield.
/// </summary>
/// <remarks>
/// If the slider is larger than the playfield, the returned <see cref="RectangleF"/> may have negative width/height.
/// </remarks>
private RectangleF calculatePossibleMovementBounds ( Slider slider )
{
var pathPositions = new List < Vector2 > ( ) ;
slider . Path . GetPathToProgress ( pathPositions , 0 , 1 ) ;
float minX = float . PositiveInfinity ;
float maxX = float . NegativeInfinity ;
float minY = float . PositiveInfinity ;
float maxY = float . NegativeInfinity ;
// Compute the bounding box of the slider.
foreach ( var pos in pathPositions )
{
minX = MathF . Min ( minX , pos . X ) ;
maxX = MathF . Max ( maxX , pos . X ) ;
minY = MathF . Min ( minY , pos . Y ) ;
maxY = MathF . Max ( maxY , pos . Y ) ;
}
// Take the circle radius into account.
float radius = ( float ) slider . Radius ;
minX - = radius ;
minY - = radius ;
maxX + = radius ;
maxY + = radius ;
// Given the bounding box of the slider (via min/max X/Y),
// the amount that the slider can move to the left is minX (with the sign flipped, since positive X is to the right),
// and the amount that it can move to the right is WIDTH - maxX.
// Same calculation applies for the Y axis.
float left = - minX ;
float right = OsuPlayfield . BASE_SIZE . X - maxX ;
float top = - minY ;
float bottom = OsuPlayfield . BASE_SIZE . Y - maxY ;
return new RectangleF ( left , top , right - left , bottom - top ) ;
}
/// <summary>
/// Shifts all nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s by the specified shift.
/// </summary>
/// <param name="slider"><see cref="Slider"/> whose nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted</param>
/// <param name="shift">The <see cref="Vector2"/> the <see cref="Slider"/>'s nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted by</param>
private void shiftNestedObjects ( Slider slider , Vector2 shift )
{
foreach ( var hitObject in slider . NestedHitObjects . Where ( o = > o is SliderTick | | o is SliderRepeat ) )
{
if ( ! ( hitObject is OsuHitObject osuHitObject ) )
continue ;
osuHitObject . Position + = shift ;
}
}
/// <summary>
/// Clamp a position to playfield, keeping a specified distance from the edges.
/// </summary>
/// <param name="position">The position to be clamped.</param>
/// <param name="padding">The minimum distance allowed from playfield edges.</param>
/// <returns>The clamped position.</returns>
private Vector2 clampToPlayfieldWithPadding ( Vector2 position , float padding )
{
return new Vector2 (
Math . Clamp ( position . X , padding , OsuPlayfield . BASE_SIZE . X - padding ) ,
Math . Clamp ( position . Y , padding , OsuPlayfield . BASE_SIZE . Y - padding )
) ;
}
2022-03-09 12:52:11 +00:00
public interface IObjectPositionInfo
2022-03-09 12:36:31 +00:00
{
/// <summary>
/// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle.
/// </summary>
/// <remarks>
/// <see cref="RelativeAngle"/> of the first hit object in a beatmap represents the absolute angle from playfield center to the object.
/// </remarks>
/// <example>
/// If <see cref="RelativeAngle"/> is 0, the player's cursor doesn't need to change its direction of movement when passing
/// the previous object to reach this one.
/// </example>
float RelativeAngle { get ; set ; }
/// <summary>
/// The jump distance from the previous hit object to this one.
/// </summary>
/// <remarks>
/// <see cref="DistanceFromPrevious"/> of the first hit object in a beatmap is relative to the playfield center.
/// </remarks>
float DistanceFromPrevious { get ; set ; }
/// <summary>
2022-03-09 12:52:11 +00:00
/// The hit object associated with this <see cref="IObjectPositionInfo"/>.
2022-03-09 12:36:31 +00:00
/// </summary>
OsuHitObject HitObject { get ; }
}
2022-03-09 12:52:11 +00:00
private class ObjectPositionInfo : IObjectPositionInfo
2022-03-09 12:36:31 +00:00
{
public float RelativeAngle { get ; set ; }
public float DistanceFromPrevious { get ; set ; }
public Vector2 PositionOriginal { get ; }
2022-03-09 12:52:11 +00:00
public Vector2 PositionModified { get ; set ; }
public Vector2 EndPositionModified { get ; set ; }
2022-03-09 12:36:31 +00:00
public OsuHitObject HitObject { get ; }
2022-03-09 12:52:11 +00:00
public ObjectPositionInfo ( OsuHitObject hitObject )
2022-03-09 12:36:31 +00:00
{
2022-03-09 12:52:11 +00:00
PositionModified = PositionOriginal = hitObject . Position ;
2022-03-09 13:04:35 +00:00
EndPositionModified = hitObject . EndPosition ;
2022-03-09 12:36:31 +00:00
HitObject = hitObject ;
}
}
}
}