2021-04-25 06:39:36 +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.
2022-03-09 15:58:36 +08:00
#nullable enable
2021-04-25 06:39:36 +08:00
using System ;
2021-06-05 23:04:58 +08:00
using System.Collections.Generic ;
2022-03-09 15:58:36 +08:00
using System.Diagnostics ;
2021-05-15 08:07:24 +08:00
using System.Linq ;
2021-07-01 10:06:14 +08:00
using osu.Framework.Graphics.Primitives ;
2021-04-28 04:19:04 +08:00
using osu.Framework.Utils ;
2021-04-25 06:39:36 +08:00
using osu.Game.Beatmaps ;
using osu.Game.Rulesets.Mods ;
2021-04-25 07:34:39 +08:00
using osu.Game.Rulesets.Objects ;
2021-05-01 10:01:43 +08:00
using osu.Game.Rulesets.Osu.Beatmaps ;
2021-04-25 06:39:36 +08:00
using osu.Game.Rulesets.Osu.Objects ;
using osu.Game.Rulesets.Osu.UI ;
2021-06-24 13:19:42 +08:00
using osu.Game.Rulesets.Osu.Utils ;
2021-04-25 06:39:36 +08:00
using osuTK ;
namespace osu.Game.Rulesets.Osu.Mods
{
2021-04-25 07:34:39 +08:00
/// <summary>
/// Mod that randomises the positions of the <see cref="HitObject"/>s
/// </summary>
2021-04-28 01:39:58 +08:00
public class OsuModRandom : ModRandom , IApplicableToBeatmap
2021-04-25 06:39:36 +08:00
{
2021-04-25 07:43:32 +08:00
public override string Description = > "It never gets boring!" ;
2021-04-25 07:34:39 +08:00
2021-05-24 13:24:56 +08:00
private static readonly float playfield_diagonal = OsuPlayfield . BASE_SIZE . LengthFast ;
2022-03-08 11:45:16 +08:00
private static readonly Vector2 playfield_centre = OsuPlayfield . BASE_SIZE / 2 ;
2021-04-25 07:34:39 +08:00
2021-06-29 12:33:40 +08:00
/// <summary>
2021-07-11 22:46:30 +08:00
/// Number of previous hitobjects to be shifted together when another object is being moved.
2021-06-29 12:33:40 +08:00
/// </summary>
2021-07-11 22:46:30 +08:00
private const int preceding_hitobjects_to_shift = 10 ;
2021-06-29 12:33:40 +08:00
2022-03-09 15:58:36 +08:00
private Random ? rng ;
2021-05-26 15:37:30 +08:00
2021-05-13 00:11:50 +08:00
public void ApplyToBeatmap ( IBeatmap beatmap )
2021-04-25 06:39:36 +08:00
{
2021-05-13 00:11:50 +08:00
if ( ! ( beatmap is OsuBeatmap osuBeatmap ) )
2021-05-01 10:01:43 +08:00
return ;
2021-05-14 13:13:35 +08:00
var hitObjects = osuBeatmap . HitObjects ;
2021-05-14 07:50:11 +08:00
Seed . Value ? ? = RNG . Next ( ) ;
2021-04-28 02:44:36 +08:00
2021-05-26 15:37:30 +08:00
rng = new Random ( ( int ) Seed . Value ) ;
2021-04-26 05:57:01 +08:00
2022-03-08 11:45:16 +08:00
var randomObjects = randomiseObjects ( hitObjects ) ;
2021-04-26 05:57:01 +08:00
2022-03-08 11:50:30 +08:00
applyRandomisation ( hitObjects , randomObjects ) ;
}
2022-03-08 12:07:10 +08:00
/// <summary>
/// Randomise the position of each hit object and return a list of <see cref="RandomObjectInfo"/>s describing how each hit object should be placed.
/// </summary>
/// <param name="hitObjects">A list of <see cref="OsuHitObject"/>s to have their positions randomised.</param>
/// <returns>A list of <see cref="RandomObjectInfo"/>s describing how each hit object should be placed.</returns>
2022-03-08 11:50:30 +08:00
private List < RandomObjectInfo > randomiseObjects ( IEnumerable < OsuHitObject > hitObjects )
{
2022-03-09 15:58:36 +08:00
Debug . Assert ( rng ! = null , $"{nameof(ApplyToBeatmap)} was not called before randomising objects" ) ;
2022-03-08 11:50:30 +08:00
var randomObjects = new List < RandomObjectInfo > ( ) ;
2022-03-09 15:58:36 +08:00
RandomObjectInfo ? previous = null ;
2022-03-08 11:50:30 +08:00
float rateOfChangeMultiplier = 0 ;
foreach ( OsuHitObject hitObject in hitObjects )
{
var current = new RandomObjectInfo ( hitObject ) ;
randomObjects . Add ( current ) ;
// rateOfChangeMultiplier only changes every 5 iterations in a combo
// to prevent shaky-line-shaped streams
if ( hitObject . IndexInCurrentCombo % 5 = = 0 )
rateOfChangeMultiplier = ( float ) rng . NextDouble ( ) * 2 - 1 ;
if ( previous = = null )
{
2022-03-09 13:09:33 +08:00
current . DistanceFromPrevious = ( float ) ( rng . NextDouble ( ) * OsuPlayfield . BASE_SIZE . X / 2 ) ;
2022-03-08 11:50:30 +08:00
current . RelativeAngle = ( float ) ( rng . NextDouble ( ) * 2 * Math . PI - Math . PI ) ;
}
else
{
2022-03-09 13:09:33 +08:00
current . DistanceFromPrevious = Vector2 . Distance ( previous . EndPositionOriginal , current . PositionOriginal ) ;
2022-03-08 11:50:30 +08:00
// The max. angle (relative to the angle of the vector pointing from the 2nd last to the last hit object)
// is proportional to the distance between the last and the current hit object
// to allow jumps and prevent too sharp turns during streams.
// Allow maximum jump angle when jump distance is more than half of playfield diagonal length
2022-03-09 13:09:33 +08:00
current . RelativeAngle = rateOfChangeMultiplier * 2 * ( float ) Math . PI * Math . Min ( 1f , current . DistanceFromPrevious / ( playfield_diagonal * 0.5f ) ) ;
2022-03-08 11:50:30 +08:00
}
previous = current ;
}
return randomObjects ;
}
2022-03-08 12:07:10 +08:00
/// <summary>
/// Reposition the hit objects according to the information in <paramref name="randomObjects"/>.
/// </summary>
/// <param name="hitObjects">The hit objects to be repositioned.</param>
/// <param name="randomObjects">A list of <see cref="RandomObjectInfo"/> describing how each hit object should be placed.</param>
2022-03-08 11:50:30 +08:00
private void applyRandomisation ( IReadOnlyList < OsuHitObject > hitObjects , IReadOnlyList < RandomObjectInfo > randomObjects )
{
2022-03-09 15:58:36 +08:00
RandomObjectInfo ? previous = null ;
2021-04-26 05:57:01 +08:00
2021-05-14 13:13:35 +08:00
for ( int i = 0 ; i < hitObjects . Count ; i + + )
2021-04-25 06:39:36 +08:00
{
2021-05-24 13:19:10 +08:00
var hitObject = hitObjects [ i ] ;
2021-05-14 13:13:35 +08:00
2022-03-08 11:45:16 +08:00
var current = randomObjects [ i ] ;
2021-04-26 05:57:01 +08:00
2021-05-24 13:33:07 +08:00
if ( hitObject is Spinner )
2021-05-26 15:36:14 +08:00
{
2021-05-26 15:44:44 +08:00
previous = null ;
2021-05-24 13:33:07 +08:00
continue ;
2021-05-26 15:36:14 +08:00
}
2021-05-24 13:33:07 +08:00
2022-03-09 13:18:57 +08:00
computeRandomisedPosition ( current , previous , i > 1 ? randomObjects [ i - 2 ] : null ) ;
2021-05-15 05:04:09 +08:00
2021-07-11 22:01:28 +08:00
// Move hit objects back into the playfield if they are outside of it
Vector2 shift = Vector2 . Zero ;
2021-06-29 13:36:30 +08:00
2021-07-13 18:37:02 +08:00
switch ( hitObject )
2021-07-11 22:01:28 +08:00
{
2021-07-13 18:37:02 +08:00
case HitCircle circle :
shift = clampHitCircleToPlayfield ( circle , current ) ;
break ;
case Slider slider :
shift = clampSliderToPlayfield ( slider , current ) ;
break ;
2021-07-11 22:01:28 +08:00
}
2021-05-15 05:04:09 +08:00
2021-07-11 22:01:28 +08:00
if ( shift ! = Vector2 . Zero )
2021-06-29 12:33:40 +08:00
{
2021-07-11 22:01:28 +08:00
var toBeShifted = new List < OsuHitObject > ( ) ;
2021-06-29 12:33:40 +08:00
2021-07-11 22:46:30 +08:00
for ( int j = i - 1 ; j > = i - preceding_hitobjects_to_shift & & j > = 0 ; j - - )
2021-06-29 12:33:40 +08:00
{
2021-07-11 22:01:28 +08:00
// only shift hit circles
if ( ! ( hitObjects [ j ] is HitCircle ) ) break ;
2021-06-29 12:33:40 +08:00
2021-07-11 22:01:28 +08:00
toBeShifted . Add ( hitObjects [ j ] ) ;
2021-06-29 12:33:40 +08:00
}
2021-07-11 22:01:28 +08:00
if ( toBeShifted . Count > 0 )
applyDecreasingShift ( toBeShifted , shift ) ;
2021-06-29 12:33:40 +08:00
}
2021-04-26 05:57:01 +08:00
2021-05-26 15:44:44 +08:00
previous = current ;
2021-04-25 06:39:36 +08:00
}
}
2021-04-26 05:57:01 +08:00
2022-03-08 11:45:16 +08:00
/// <summary>
2022-03-08 12:07:10 +08:00
/// Compute the randomised position of a hit object while attempting to keep it inside the playfield.
2022-03-08 11:45:16 +08:00
/// </summary>
2022-03-08 12:07:10 +08:00
/// <param name="current">The <see cref="RandomObjectInfo"/> representing the hit object to have the randomised position computed for.</param>
2022-03-09 13:18:57 +08:00
/// <param name="previous">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the current one.</param>
/// <param name="beforePrevious">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
2022-03-09 15:58:36 +08:00
private void computeRandomisedPosition ( RandomObjectInfo current , RandomObjectInfo ? previous , RandomObjectInfo ? beforePrevious )
2022-03-08 11:45:16 +08:00
{
2022-03-09 13:18:57 +08:00
float previousAbsoluteAngle = 0f ;
if ( previous ! = null )
{
2022-03-09 16:46:41 +08:00
Vector2 earliestPosition = beforePrevious ? . HitObject . EndPosition ? ? playfield_centre ;
2022-03-09 13:18:57 +08:00
Vector2 relativePosition = previous . HitObject . Position - earliestPosition ;
previousAbsoluteAngle = ( float ) Math . Atan2 ( relativePosition . Y , relativePosition . X ) ;
}
2022-03-08 11:45:16 +08:00
float absoluteAngle = previousAbsoluteAngle + current . RelativeAngle ;
2021-04-26 05:57:01 +08:00
var posRelativeToPrev = new Vector2 (
2022-03-09 13:09:33 +08:00
current . DistanceFromPrevious * ( float ) Math . Cos ( absoluteAngle ) ,
current . DistanceFromPrevious * ( float ) Math . Sin ( absoluteAngle )
2021-04-26 05:57:01 +08:00
) ;
2022-03-08 11:45:16 +08:00
Vector2 lastEndPosition = previous ? . EndPositionRandomised ? ? playfield_centre ;
2021-04-26 05:57:01 +08:00
2022-03-08 11:45:16 +08:00
posRelativeToPrev = OsuHitObjectGenerationUtils . RotateAwayFromEdge ( lastEndPosition , posRelativeToPrev ) ;
2021-05-24 13:24:56 +08:00
2022-03-08 11:45:16 +08:00
current . PositionRandomised = lastEndPosition + posRelativeToPrev ;
2021-05-15 05:04:09 +08:00
}
2021-07-13 18:37:17 +08:00
/// <summary>
/// Move the randomised position of a hit circle so that it fits inside the playfield.
/// </summary>
/// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
private Vector2 clampHitCircleToPlayfield ( HitCircle circle , RandomObjectInfo objectInfo )
{
var previousPosition = objectInfo . PositionRandomised ;
objectInfo . EndPositionRandomised = objectInfo . PositionRandomised = clampToPlayfieldWithPadding (
objectInfo . PositionRandomised ,
( float ) circle . Radius
) ;
circle . Position = objectInfo . PositionRandomised ;
return objectInfo . PositionRandomised - previousPosition ;
}
2021-05-26 03:32:18 +08: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>
2021-07-11 22:01:28 +08:00
/// <returns>The deviation from the original randomised position in order to fit within the playfield.</returns>
private Vector2 clampSliderToPlayfield ( Slider slider , RandomObjectInfo objectInfo )
2021-05-15 05:04:09 +08:00
{
2021-07-11 22:49:23 +08:00
var possibleMovementBounds = calculatePossibleMovementBounds ( slider ) ;
2021-06-04 22:17:54 +08:00
2021-07-11 22:01:28 +08:00
var previousPosition = objectInfo . PositionRandomised ;
2021-06-29 12:33:40 +08:00
2021-07-05 09:16:01 +08:00
// Clamp slider position to the placement area
2021-07-01 10:59:06 +08:00
// If the slider is larger than the playfield, force it to stay at the original position
2021-10-27 12:04:41 +08:00
float newX = possibleMovementBounds . Width < 0
2021-07-11 22:01:28 +08:00
? objectInfo . PositionOriginal . X
2021-07-11 22:49:23 +08:00
: Math . Clamp ( previousPosition . X , possibleMovementBounds . Left , possibleMovementBounds . Right ) ;
2021-07-01 10:59:06 +08:00
2021-10-27 12:04:41 +08:00
float newY = possibleMovementBounds . Height < 0
2021-07-11 22:01:28 +08:00
? objectInfo . PositionOriginal . Y
2021-07-11 22:49:23 +08:00
: Math . Clamp ( previousPosition . Y , possibleMovementBounds . Top , possibleMovementBounds . Bottom ) ;
2021-07-01 10:59:06 +08:00
2021-07-11 22:01:28 +08:00
slider . Position = objectInfo . PositionRandomised = new Vector2 ( newX , newY ) ;
objectInfo . EndPositionRandomised = slider . EndPosition ;
2021-06-04 22:17:54 +08:00
2021-07-11 22:01:28 +08:00
shiftNestedObjects ( slider , objectInfo . PositionRandomised - objectInfo . PositionOriginal ) ;
2021-06-04 22:17:54 +08:00
2021-07-11 22:01:28 +08:00
return objectInfo . PositionRandomised - previousPosition ;
2021-06-29 12:33:40 +08: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 + + )
{
2021-06-29 13:36:30 +08:00
var hitObject = hitObjects [ i ] ;
2021-06-29 12:56:05 +08:00
// The first object is shifted by a vector slightly smaller than shift
// The last object is shifted by a vector slightly larger than zero
2021-06-29 13:36:30 +08:00
Vector2 position = hitObject . Position + shift * ( ( hitObjects . Count - i ) / ( float ) ( hitObjects . Count + 1 ) ) ;
2021-06-29 12:33:40 +08:00
2021-07-11 22:01:28 +08:00
hitObject . Position = clampToPlayfieldWithPadding ( position , ( float ) hitObject . Radius ) ;
2021-06-29 12:33:40 +08:00
}
2021-06-04 22:17:54 +08:00
}
/// <summary>
2021-07-10 18:13:36 +08:00
/// 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.
2021-06-04 22:17:54 +08:00
/// </summary>
2021-07-05 09:16:01 +08:00
/// <remarks>
/// If the slider is larger than the playfield, the returned <see cref="RectangleF"/> may have negative width/height.
/// </remarks>
2021-07-10 18:13:36 +08:00
private RectangleF calculatePossibleMovementBounds ( Slider slider )
2021-06-04 22:17:54 +08:00
{
2021-06-05 23:04:58 +08:00
var pathPositions = new List < Vector2 > ( ) ;
slider . Path . GetPathToProgress ( pathPositions , 0 , 1 ) ;
2021-07-10 18:13:36 +08:00
float minX = float . PositiveInfinity ;
float maxX = float . NegativeInfinity ;
2021-05-26 03:32:18 +08:00
2021-07-10 18:13:36 +08:00
float minY = float . PositiveInfinity ;
float maxY = float . NegativeInfinity ;
// Compute the bounding box of the slider.
2021-06-05 23:13:08 +08:00
foreach ( var pos in pathPositions )
2021-06-04 22:17:54 +08:00
{
2021-07-10 18:13:36 +08:00
minX = MathF . Min ( minX , pos . X ) ;
maxX = MathF . Max ( maxX , pos . X ) ;
minY = MathF . Min ( minY , pos . Y ) ;
maxY = MathF . Max ( maxY , pos . Y ) ;
2021-06-04 22:17:54 +08:00
}
2021-06-05 23:13:08 +08:00
2021-07-10 18:13:36 +08:00
// Take the circle radius into account.
2021-10-27 12:04:41 +08:00
float radius = ( float ) slider . Radius ;
2021-06-29 13:36:30 +08:00
2021-07-10 18:13:36 +08:00
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 ;
2021-07-01 10:06:14 +08:00
2021-07-10 18:13:36 +08:00
return new RectangleF ( left , top , right - left , bottom - top ) ;
2021-05-26 03:32:18 +08:00
}
/// <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 ;
2021-06-04 22:23:03 +08:00
osuHitObject . Position + = shift ;
2021-05-26 03:32:18 +08:00
}
2021-04-26 05:57:01 +08:00
}
2021-07-11 22:01:28 +08:00
/// <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 )
) ;
2021-06-29 13:36:30 +08:00
}
2021-05-26 15:36:14 +08:00
private class RandomObjectInfo
2021-05-13 00:11:50 +08:00
{
2022-03-08 12:07:10 +08: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>
2022-03-08 11:45:16 +08:00
public float RelativeAngle { get ; set ; }
2022-03-08 12:07:10 +08:00
/// <summary>
/// The jump distance from the previous hit object to this one.
/// </summary>
/// <remarks>
2022-03-09 13:09:33 +08:00
/// <see cref="DistanceFromPrevious"/> of the first hit object in a beatmap is relative to the playfield center.
2022-03-08 12:07:10 +08:00
/// </remarks>
2022-03-09 13:09:33 +08:00
public float DistanceFromPrevious { get ; set ; }
2021-05-01 10:01:43 +08:00
2021-05-26 15:31:25 +08:00
public Vector2 PositionOriginal { get ; }
public Vector2 PositionRandomised { get ; set ; }
2021-05-01 10:01:43 +08:00
2021-05-26 15:31:25 +08:00
public Vector2 EndPositionOriginal { get ; }
public Vector2 EndPositionRandomised { get ; set ; }
2021-05-13 00:11:50 +08:00
2022-03-09 13:18:57 +08:00
public OsuHitObject HitObject { get ; }
2021-05-24 13:19:10 +08:00
public RandomObjectInfo ( OsuHitObject hitObject )
2021-05-01 10:01:43 +08:00
{
2021-05-24 13:19:10 +08:00
PositionRandomised = PositionOriginal = hitObject . Position ;
EndPositionRandomised = EndPositionOriginal = hitObject . EndPosition ;
2022-03-09 13:18:57 +08:00
HitObject = hitObject ;
2021-05-01 10:01:43 +08:00
}
2021-05-24 13:19:10 +08:00
}
2021-05-01 10:01:43 +08:00
}
2021-04-25 06:39:36 +08:00
}