diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index 7479c3120a..fea9246035 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -4,19 +4,14 @@ #nullable enable using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using osu.Framework.Graphics.Primitives; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; -using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.Utils; -using osuTK; namespace osu.Game.Rulesets.Osu.Mods { @@ -28,12 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Description => "It never gets boring!"; private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast; - private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2; - - /// - /// Number of previous hitobjects to be shifted together when another object is being moved. - /// - private const int preceding_hitobjects_to_shift = 10; private Random? rng; @@ -42,330 +31,33 @@ namespace osu.Game.Rulesets.Osu.Mods if (!(beatmap is OsuBeatmap osuBeatmap)) return; - var hitObjects = osuBeatmap.HitObjects; - Seed.Value ??= RNG.Next(); rng = new Random((int)Seed.Value); - var randomObjects = randomiseObjects(hitObjects); + var positionInfos = OsuHitObjectGenerationUtils.GeneratePositionInfos(osuBeatmap.HitObjects); - applyRandomisation(hitObjects, randomObjects); - } - - /// - /// Randomise the position of each hit object and return a list of s describing how each hit object should be placed. - /// - /// A list of s to have their positions randomised. - /// A list of s describing how each hit object should be placed. - private List randomiseObjects(IEnumerable hitObjects) - { - Debug.Assert(rng != null, $"{nameof(ApplyToBeatmap)} was not called before randomising objects"); - - var randomObjects = new List(); - RandomObjectInfo? previous = null; float rateOfChangeMultiplier = 0; - foreach (OsuHitObject hitObject in hitObjects) + foreach (var positionInfo in positionInfos) { - 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) + if (positionInfo.HitObject.IndexInCurrentCombo % 5 == 0) rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1; - if (previous == null) + if (positionInfo == positionInfos.First()) { - current.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2); - current.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); + positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2); + positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); } else { - current.DistanceFromPrevious = Vector2.Distance(previous.EndPositionOriginal, current.PositionOriginal); - - // 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 - current.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, current.DistanceFromPrevious / (playfield_diagonal * 0.5f)); + positionInfo.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, positionInfo.DistanceFromPrevious / (playfield_diagonal * 0.5f)); } - - previous = current; } - return randomObjects; - } - - /// - /// Reposition the hit objects according to the information in . - /// - /// The hit objects to be repositioned. - /// A list of describing how each hit object should be placed. - private void applyRandomisation(IReadOnlyList hitObjects, IReadOnlyList randomObjects) - { - RandomObjectInfo? previous = null; - - for (int i = 0; i < hitObjects.Count; i++) - { - var hitObject = hitObjects[i]; - - var current = randomObjects[i]; - - if (hitObject is Spinner) - { - previous = null; - continue; - } - - computeRandomisedPosition(current, previous, i > 1 ? randomObjects[i - 2] : null); - - // 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(); - - 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; - } - } - - /// - /// Compute the randomised position of a hit object while attempting to keep it inside the playfield. - /// - /// The representing the hit object to have the randomised position computed for. - /// The representing the hit object immediately preceding the current one. - /// The representing the hit object immediately preceding the one. - private void computeRandomisedPosition(RandomObjectInfo current, RandomObjectInfo? previous, RandomObjectInfo? beforePrevious) - { - 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) - ); - - Vector2 lastEndPosition = previous?.EndPositionRandomised ?? playfield_centre; - - posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastEndPosition, posRelativeToPrev); - - current.PositionRandomised = lastEndPosition + posRelativeToPrev; - } - - /// - /// Move the randomised position of a hit circle so that it fits inside the playfield. - /// - /// The deviation from the original randomised position in order to fit within the playfield. - 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; - } - - /// - /// Moves the and all necessary nested s into the if they aren't already. - /// - /// The deviation from the original randomised position in order to fit within the playfield. - private Vector2 clampSliderToPlayfield(Slider slider, RandomObjectInfo objectInfo) - { - var possibleMovementBounds = calculatePossibleMovementBounds(slider); - - var previousPosition = objectInfo.PositionRandomised; - - // 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 - ? objectInfo.PositionOriginal.X - : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right); - - float newY = possibleMovementBounds.Height < 0 - ? objectInfo.PositionOriginal.Y - : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom); - - slider.Position = objectInfo.PositionRandomised = new Vector2(newX, newY); - objectInfo.EndPositionRandomised = slider.EndPosition; - - shiftNestedObjects(slider, objectInfo.PositionRandomised - objectInfo.PositionOriginal); - - return objectInfo.PositionRandomised - previousPosition; - } - - /// - /// Decreasingly shift a list of 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. - /// - /// The list of hit objects to be shifted. - /// The amount to be shifted. - private void applyDecreasingShift(IList 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); - } - } - - /// - /// Calculates a which contains all of the possible movements of the slider (in relative X/Y coordinates) - /// such that the entire slider is inside the playfield. - /// - /// - /// If the slider is larger than the playfield, the returned may have negative width/height. - /// - private RectangleF calculatePossibleMovementBounds(Slider slider) - { - var pathPositions = new List(); - 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); - } - - /// - /// Shifts all nested s and s by the specified shift. - /// - /// whose nested s and s should be shifted - /// The the 's nested s and s should be shifted by - 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; - } - } - - /// - /// Clamp a position to playfield, keeping a specified distance from the edges. - /// - /// The position to be clamped. - /// The minimum distance allowed from playfield edges. - /// The clamped position. - 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) - ); - } - - private class RandomObjectInfo - { - /// - /// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle. - /// - /// - /// of the first hit object in a beatmap represents the absolute angle from playfield center to the object. - /// - /// - /// If is 0, the player's cursor doesn't need to change its direction of movement when passing - /// the previous object to reach this one. - /// - public float RelativeAngle { get; set; } - - /// - /// The jump distance from the previous hit object to this one. - /// - /// - /// of the first hit object in a beatmap is relative to the playfield center. - /// - public float DistanceFromPrevious { get; set; } - - public Vector2 PositionOriginal { get; } - public Vector2 PositionRandomised { get; set; } - - public Vector2 EndPositionOriginal { get; } - public Vector2 EndPositionRandomised { get; set; } - - public OsuHitObject HitObject { get; } - - public RandomObjectInfo(OsuHitObject hitObject) - { - PositionRandomised = PositionOriginal = hitObject.Position; - EndPositionRandomised = EndPositionOriginal = hitObject.EndPosition; - HitObject = hitObject; - } + osuBeatmap.HitObjects = OsuHitObjectGenerationUtils.RepositionHitObjects(positionInfos); } } } diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs index 97a4b14a62..da73c2addb 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -11,7 +11,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Utils { - public static class OsuHitObjectGenerationUtils + public static partial class OsuHitObjectGenerationUtils { // The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle. // The closer the hit objects draw to the border, the sharper the turn diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs new file mode 100644 index 0000000000..d1bc3b45df --- /dev/null +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs @@ -0,0 +1,340 @@ +// 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.Graphics.Primitives; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osuTK; + +#nullable enable + +namespace osu.Game.Rulesets.Osu.Utils +{ + public static partial class OsuHitObjectGenerationUtils + { + /// + /// Number of previous hitobjects to be shifted together when an object is being moved. + /// + private const int preceding_hitobjects_to_shift = 10; + + private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2; + + /// + /// Generate a list of s containing information for how the given list of + /// s are positioned. + /// + /// A list of s to process. + /// A list of s describing how each hit object is positioned relative to the previous one. + public static List GeneratePositionInfos(IEnumerable hitObjects) + { + var positionInfos = new List(); + 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; + + positionInfos.Add(new ObjectPositionInfo(hitObject) + { + RelativeAngle = relativeAngle, + DistanceFromPrevious = relativePosition.Length + }); + + previousPosition = hitObject.EndPosition; + previousAngle = absoluteAngle; + } + + return positionInfos; + } + + /// + /// Reposition the hit objects according to the information in . + /// + /// Position information for each hit object. + /// The repositioned hit objects. + public static List RepositionHitObjects(IEnumerable objectPositionInfos) + { + List workingObjects = objectPositionInfos.Select(o => new WorkingObject(o)).ToList(); + WorkingObject? previous = null; + + for (int i = 0; i < workingObjects.Count; i++) + { + var current = workingObjects[i]; + var hitObject = current.HitObject; + + if (hitObject is Spinner) + { + previous = null; + continue; + } + + computeModifiedPosition(current, previous, i > 1 ? workingObjects[i - 2] : null); + + // Move hit objects back into the playfield if they are outside of it + Vector2 shift = Vector2.Zero; + + switch (hitObject) + { + case HitCircle _: + shift = clampHitCircleToPlayfield(current); + break; + + case Slider _: + shift = clampSliderToPlayfield(current); + break; + } + + if (shift != Vector2.Zero) + { + var toBeShifted = new List(); + + for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--) + { + // only shift hit circles + if (!(workingObjects[j].HitObject is HitCircle)) break; + + toBeShifted.Add(workingObjects[j].HitObject); + } + + if (toBeShifted.Count > 0) + applyDecreasingShift(toBeShifted, shift); + } + + previous = current; + } + + return workingObjects.Select(p => p.HitObject).ToList(); + } + + /// + /// Compute the modified position of a hit object while attempting to keep it inside the playfield. + /// + /// The representing the hit object to have the modified position computed for. + /// The representing the hit object immediately preceding the current one. + /// The representing the hit object immediately preceding the one. + private static void computeModifiedPosition(WorkingObject current, WorkingObject? previous, WorkingObject? beforePrevious) + { + 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.PositionInfo.RelativeAngle; + + var posRelativeToPrev = new Vector2( + current.PositionInfo.DistanceFromPrevious * (float)Math.Cos(absoluteAngle), + current.PositionInfo.DistanceFromPrevious * (float)Math.Sin(absoluteAngle) + ); + + Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre; + + posRelativeToPrev = RotateAwayFromEdge(lastEndPosition, posRelativeToPrev); + + current.PositionModified = lastEndPosition + posRelativeToPrev; + } + + /// + /// Move the modified position of a so that it fits inside the playfield. + /// + /// The deviation from the original modified position in order to fit within the playfield. + private static Vector2 clampHitCircleToPlayfield(WorkingObject workingObject) + { + var previousPosition = workingObject.PositionModified; + workingObject.EndPositionModified = workingObject.PositionModified = clampToPlayfieldWithPadding( + workingObject.PositionModified, + (float)workingObject.HitObject.Radius + ); + + workingObject.HitObject.Position = workingObject.PositionModified; + + return workingObject.PositionModified - previousPosition; + } + + /// + /// Moves the and all necessary nested s into the if they aren't already. + /// + /// The deviation from the original modified position in order to fit within the playfield. + private static Vector2 clampSliderToPlayfield(WorkingObject workingObject) + { + var slider = (Slider)workingObject.HitObject; + var possibleMovementBounds = calculatePossibleMovementBounds(slider); + + var previousPosition = workingObject.PositionModified; + + // 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 + ? workingObject.PositionOriginal.X + : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right); + + float newY = possibleMovementBounds.Height < 0 + ? workingObject.PositionOriginal.Y + : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom); + + slider.Position = workingObject.PositionModified = new Vector2(newX, newY); + workingObject.EndPositionModified = slider.EndPosition; + + shiftNestedObjects(slider, workingObject.PositionModified - workingObject.PositionOriginal); + + return workingObject.PositionModified - previousPosition; + } + + /// + /// Decreasingly shift a list of 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. + /// + /// The list of hit objects to be shifted. + /// The amount to be shifted. + private static void applyDecreasingShift(IList 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); + } + } + + /// + /// Calculates a which contains all of the possible movements of the slider (in relative X/Y coordinates) + /// such that the entire slider is inside the playfield. + /// + /// + /// If the slider is larger than the playfield, the returned may have negative width/height. + /// + private static RectangleF calculatePossibleMovementBounds(Slider slider) + { + var pathPositions = new List(); + 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); + } + + /// + /// Shifts all nested s and s by the specified shift. + /// + /// whose nested s and s should be shifted + /// The the 's nested s and s should be shifted by + private static 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; + } + } + + /// + /// Clamp a position to playfield, keeping a specified distance from the edges. + /// + /// The position to be clamped. + /// The minimum distance allowed from playfield edges. + /// The clamped position. + private static 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) + ); + } + + public class ObjectPositionInfo + { + /// + /// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle. + /// + /// + /// of the first hit object in a beatmap represents the absolute angle from playfield center to the object. + /// + /// + /// If is 0, the player's cursor doesn't need to change its direction of movement when passing + /// the previous object to reach this one. + /// + public float RelativeAngle { get; set; } + + /// + /// The jump distance from the previous hit object to this one. + /// + /// + /// of the first hit object in a beatmap is relative to the playfield center. + /// + public float DistanceFromPrevious { get; set; } + + /// + /// The hit object associated with this . + /// + public OsuHitObject HitObject { get; } + + public ObjectPositionInfo(OsuHitObject hitObject) + { + HitObject = hitObject; + } + } + + private class WorkingObject + { + public Vector2 PositionOriginal { get; } + public Vector2 PositionModified { get; set; } + public Vector2 EndPositionModified { get; set; } + + public ObjectPositionInfo PositionInfo { get; } + public OsuHitObject HitObject => PositionInfo.HitObject; + + public WorkingObject(ObjectPositionInfo positionInfo) + { + PositionInfo = positionInfo; + PositionModified = PositionOriginal = HitObject.Position; + EndPositionModified = HitObject.EndPosition; + } + } + } +}