diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
index 96c02a508b..056a325dce 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Collections.Generic;
using System.Linq;
using osu.Framework.Localisation;
using osu.Framework.Utils;
@@ -9,6 +10,7 @@ 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;
@@ -25,40 +27,100 @@ namespace osu.Game.Rulesets.Osu.Mods
private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast;
- private Random? rng;
+ private Random random = null!;
public void ApplyToBeatmap(IBeatmap beatmap)
{
- if (!(beatmap is OsuBeatmap osuBeatmap))
+ if (beatmap is not OsuBeatmap osuBeatmap)
return;
Seed.Value ??= RNG.Next();
- rng = new Random((int)Seed.Value);
+ random = new Random((int)Seed.Value);
var positionInfos = OsuHitObjectGenerationUtils.GeneratePositionInfos(osuBeatmap.HitObjects);
- float rateOfChangeMultiplier = 0;
+ // Offsets the angles of all hit objects in a "section" by the same amount.
+ float sectionOffset = 0;
- foreach (var positionInfo in positionInfos)
+ // Whether the angles are positive or negative (clockwise or counter-clockwise flow).
+ bool flowDirection = false;
+
+ for (int i = 0; i < positionInfos.Count; i++)
{
- // rateOfChangeMultiplier only changes every 5 iterations in a combo
- // to prevent shaky-line-shaped streams
- if (positionInfo.HitObject.IndexInCurrentCombo % 5 == 0)
- rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1;
-
- if (positionInfo == positionInfos.First())
+ if (shouldStartNewSection(osuBeatmap, positionInfos, i))
{
- positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2);
- positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
+ sectionOffset = OsuHitObjectGenerationUtils.RandomGaussian(random, 0, 0.0008f);
+ flowDirection = !flowDirection;
+ }
+
+ if (i == 0)
+ {
+ positionInfos[i].DistanceFromPrevious = (float)(random.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2);
+ positionInfos[i].RelativeAngle = (float)(random.NextDouble() * 2 * Math.PI - Math.PI);
}
else
{
- positionInfo.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, positionInfo.DistanceFromPrevious / (playfield_diagonal * 0.5f));
+ // Offsets only the angle of the current hit object if a flow change occurs.
+ float flowChangeOffset = 0;
+
+ // Offsets only the angle of the current hit object.
+ float oneTimeOffset = OsuHitObjectGenerationUtils.RandomGaussian(random, 0, 0.002f);
+
+ if (shouldApplyFlowChange(positionInfos, i))
+ {
+ flowChangeOffset = OsuHitObjectGenerationUtils.RandomGaussian(random, 0, 0.002f);
+ flowDirection = !flowDirection;
+ }
+
+ float totalOffset =
+ // sectionOffset and oneTimeOffset should mainly affect patterns with large spacing.
+ (sectionOffset + oneTimeOffset) * positionInfos[i].DistanceFromPrevious +
+ // flowChangeOffset should mainly affect streams.
+ flowChangeOffset * (playfield_diagonal - positionInfos[i].DistanceFromPrevious);
+
+ positionInfos[i].RelativeAngle = getRelativeTargetAngle(positionInfos[i].DistanceFromPrevious, totalOffset, flowDirection);
}
}
osuBeatmap.HitObjects = OsuHitObjectGenerationUtils.RepositionHitObjects(positionInfos);
}
+
+ /// The target distance between the previous and the current .
+ /// The angle (in rad) by which the target angle should be offset.
+ /// Whether the relative angle should be positive or negative.
+ private static float getRelativeTargetAngle(float targetDistance, float offset, bool flowDirection)
+ {
+ float angle = (float)(2.16 / (1 + 200 * Math.Exp(0.036 * (targetDistance - 310))) + 0.5 + offset);
+ float relativeAngle = (float)Math.PI - angle;
+ return flowDirection ? -relativeAngle : relativeAngle;
+ }
+
+ /// Whether a new section should be started at the current .
+ private bool shouldStartNewSection(OsuBeatmap beatmap, IReadOnlyList positionInfos, int i)
+ {
+ if (i == 0)
+ return true;
+
+ // Exclude new-combo-spam and 1-2-combos.
+ bool previousObjectStartedCombo = positionInfos[Math.Max(0, i - 2)].HitObject.IndexInCurrentCombo > 1 &&
+ positionInfos[i - 1].HitObject.NewCombo;
+ bool previousObjectWasOnDownbeat = OsuHitObjectGenerationUtils.IsHitObjectOnBeat(beatmap, positionInfos[i - 1].HitObject, true);
+ bool previousObjectWasOnBeat = OsuHitObjectGenerationUtils.IsHitObjectOnBeat(beatmap, positionInfos[i - 1].HitObject);
+
+ return (previousObjectStartedCombo && random.NextDouble() < 0.6f) ||
+ previousObjectWasOnDownbeat ||
+ (previousObjectWasOnBeat && random.NextDouble() < 0.4f);
+ }
+
+ /// Whether a flow change should be applied at the current .
+ private bool shouldApplyFlowChange(IReadOnlyList positionInfos, int i)
+ {
+ // Exclude new-combo-spam and 1-2-combos.
+ bool previousObjectStartedCombo = positionInfos[Math.Max(0, i - 2)].HitObject.IndexInCurrentCombo > 1 &&
+ positionInfos[i - 1].HitObject.NewCombo;
+
+ return previousObjectStartedCombo && random.NextDouble() < 0.6f;
+ }
}
}
diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs
index 5e827d4782..3a8b3f67d0 100644
--- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs
+++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs
@@ -8,6 +8,7 @@ using System.Linq;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
@@ -186,5 +187,39 @@ namespace osu.Game.Rulesets.Osu.Utils
length * MathF.Sin(angle)
);
}
+
+ /// The beatmap hitObject is a part of.
+ /// The that should be checked.
+ /// If true, this method only returns true if hitObject is on a downbeat.
+ /// If false, it returns true if hitObject is on any beat.
+ /// true if hitObject is on a (down-)beat, false otherwise.
+ public static bool IsHitObjectOnBeat(OsuBeatmap beatmap, OsuHitObject hitObject, bool downbeatsOnly = false)
+ {
+ var timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime);
+
+ double timeSinceTimingPoint = hitObject.StartTime - timingPoint.Time;
+
+ double beatLength = timingPoint.BeatLength;
+
+ if (downbeatsOnly)
+ beatLength *= timingPoint.TimeSignature.Numerator;
+
+ // Ensure within 1ms of expected location.
+ return Math.Abs(timeSinceTimingPoint + 1) % beatLength < 2;
+ }
+
+ ///
+ /// Generates a random number from a normal distribution using the Box-Muller transform.
+ ///
+ public static float RandomGaussian(Random rng, float mean = 0, float stdDev = 1)
+ {
+ // Generate 2 random numbers in the interval (0,1].
+ // x1 must not be 0 since log(0) = undefined.
+ double x1 = 1 - rng.NextDouble();
+ double x2 = 1 - rng.NextDouble();
+
+ double stdNormal = Math.Sqrt(-2 * Math.Log(x1)) * Math.Sin(2 * Math.PI * x2);
+ return mean + stdDev * (float)stdNormal;
+ }
}
}