diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 859b6cfe76..f139a88f50 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -258,6 +258,7 @@ namespace osu.Game.Rulesets.Mania { new MultiMod(new ModWindUp(), new ModWindDown()), new ManiaModMuted(), + new ModAdaptiveSpeed() }; default: diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 5ade164566..5b936b1bf1 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -195,6 +195,7 @@ namespace osu.Game.Rulesets.Osu new OsuModMuted(), new OsuModNoScope(), new OsuModAimAssist(), + new ModAdaptiveSpeed() }; case ModType.System: diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index de0ef8d95b..dc90845d92 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -151,6 +151,7 @@ namespace osu.Game.Rulesets.Taiko { new MultiMod(new ModWindUp(), new ModWindDown()), new TaikoModMuted(), + new ModAdaptiveSpeed() }; default: diff --git a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs new file mode 100644 index 0000000000..1115b95e6f --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs @@ -0,0 +1,269 @@ +// 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.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Audio; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mods +{ + public class ModAdaptiveSpeed : Mod, IApplicableToRate, IApplicableToDrawableHitObject, IApplicableToBeatmap, IUpdatableByPlayfield + { + public override string Name => "Adaptive Speed"; + + public override string Acronym => "AS"; + + public override string Description => "Let track speed adapt to you."; + + public override ModType Type => ModType.Fun; + + public override double ScoreMultiplier => 1; + + public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModTimeRamp) }; + + [SettingSource("Initial rate", "The starting speed of the track")] + public BindableNumber InitialRate { get; } = new BindableDouble + { + MinValue = 0.5, + MaxValue = 2, + Default = 1, + Value = 1, + Precision = 0.01 + }; + + [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] + public BindableBool AdjustPitch { get; } = new BindableBool + { + Default = true, + Value = true + }; + + /// + /// The instantaneous rate of the track. + /// Every frame this mod will attempt to smoothly adjust this to meet . + /// + public BindableNumber SpeedChange { get; } = new BindableDouble + { + MinValue = min_allowable_rate, + MaxValue = max_allowable_rate, + Default = 1, + Value = 1 + }; + + // The two constants below denote the maximum allowable range of rates that `SpeedChange` can take. + // The range is purposefully wider than the range of values that `InitialRate` allows + // in order to give some leeway for change even when extreme initial rates are chosen. + private const double min_allowable_rate = 0.4d; + private const double max_allowable_rate = 2.5d; + + // The two constants below denote the maximum allowable change in rate caused by a single hit + // This prevents sudden jolts caused by a badly-timed hit. + private const double min_allowable_rate_change = 0.9d; + private const double max_allowable_rate_change = 1.11d; + + // Apply a fixed rate change when missing, allowing the player to catch up when the rate is too fast. + private const double rate_change_on_miss = 0.95d; + + private ITrack track; + private double targetRate = 1d; + + /// + /// The number of most recent track rates (approximated from how early/late each object was hit relative to the previous object) + /// which should be averaged to calculate . + /// + private const int recent_rate_count = 8; + + /// + /// Stores the most recent approximated track rates + /// which are averaged to calculate the value of . + /// + /// + /// This list is used as a double-ended queue with fixed capacity + /// (items can be enqueued/dequeued at either end of the list). + /// When time is elapsing forward, items are dequeued from the start and enqueued onto the end of the list. + /// When time is being rewound, items are dequeued from the end and enqueued onto the start of the list. + /// + /// + /// + /// The track rate approximation is calculated as follows: + /// + /// + /// Consider a hitobject which ends at 1000ms, and assume that its preceding hitobject ends at 500ms. + /// This gives a time difference of 1000 - 500 = 500ms. + /// + /// + /// Now assume that the user hit this object at 980ms rather than 1000ms. + /// When compared to the preceding hitobject, this gives 980 - 500 = 480ms. + /// + /// + /// With the above assumptions, the player is rushing / hitting early, which means that the track should speed up to match. + /// Therefore, the approximated target rate for this object would be equal to 500 / 480 * . + /// + /// + private readonly List recentRates = Enumerable.Repeat(1d, recent_rate_count).ToList(); + + /// + /// For each given in the map, this dictionary maps the object onto the latest end time of any other object + /// that precedes the end time of the given object. + /// This can be loosely interpreted as the end time of the preceding hit object in rulesets that do not have overlapping hit objects. + /// + private readonly Dictionary precedingEndTimes = new Dictionary(); + + /// + /// For each given in the map, this dictionary maps the object onto the track rate dequeued from + /// (i.e. the oldest value in the queue) when the object is hit. If the hit is then reverted, + /// the mapped value can be re-introduced to to properly rewind the queue. + /// + private readonly Dictionary ratesForRewinding = new Dictionary(); + + public ModAdaptiveSpeed() + { + InitialRate.BindValueChanged(val => + { + SpeedChange.Value = val.NewValue; + targetRate = val.NewValue; + }); + AdjustPitch.BindValueChanged(adjustPitchChanged); + } + + public void ApplyToTrack(ITrack track) + { + this.track = track; + + InitialRate.TriggerChange(); + AdjustPitch.TriggerChange(); + recentRates.Clear(); + recentRates.AddRange(Enumerable.Repeat(InitialRate.Value, recent_rate_count)); + } + + public void ApplyToSample(DrawableSample sample) + { + sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); + } + + public void Update(Playfield playfield) + { + SpeedChange.Value = Interpolation.DampContinuously(SpeedChange.Value, targetRate, 50, playfield.Clock.ElapsedFrameTime); + } + + public double ApplyToRate(double time, double rate = 1) => rate * InitialRate.Value; + + public void ApplyToDrawableHitObject(DrawableHitObject drawable) + { + drawable.OnNewResult += (o, result) => + { + if (ratesForRewinding.ContainsKey(result.HitObject)) return; + if (!shouldProcessResult(result)) return; + + ratesForRewinding.Add(result.HitObject, recentRates[0]); + recentRates.RemoveAt(0); + + recentRates.Add(Math.Clamp(getRelativeRateChange(result) * SpeedChange.Value, min_allowable_rate, max_allowable_rate)); + + updateTargetRate(); + }; + drawable.OnRevertResult += (o, result) => + { + if (!ratesForRewinding.ContainsKey(result.HitObject)) return; + if (!shouldProcessResult(result)) return; + + recentRates.Insert(0, ratesForRewinding[result.HitObject]); + ratesForRewinding.Remove(result.HitObject); + + recentRates.RemoveAt(recentRates.Count - 1); + + updateTargetRate(); + }; + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var hitObjects = getAllApplicableHitObjects(beatmap.HitObjects).ToList(); + var endTimes = hitObjects.Select(x => x.GetEndTime()).OrderBy(x => x).Distinct().ToList(); + + foreach (HitObject hitObject in hitObjects) + { + int index = endTimes.BinarySearch(hitObject.GetEndTime()); + if (index < 0) index = ~index; // BinarySearch returns the next larger element in bitwise complement if there's no exact match + index -= 1; + + if (index >= 0) + precedingEndTimes.Add(hitObject, endTimes[index]); + } + } + + private void adjustPitchChanged(ValueChangedEvent adjustPitchSetting) + { + track?.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange); + + track?.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange); + } + + private AdjustableProperty adjustmentForPitchSetting(bool adjustPitchSettingValue) + => adjustPitchSettingValue ? AdjustableProperty.Frequency : AdjustableProperty.Tempo; + + private IEnumerable getAllApplicableHitObjects(IEnumerable hitObjects) + { + foreach (var hitObject in hitObjects) + { + if (!(hitObject.HitWindows is HitWindows.EmptyHitWindows)) + yield return hitObject; + + foreach (HitObject nested in getAllApplicableHitObjects(hitObject.NestedHitObjects)) + yield return nested; + } + } + + private bool shouldProcessResult(JudgementResult result) + { + if (!result.Type.AffectsAccuracy()) return false; + if (!precedingEndTimes.ContainsKey(result.HitObject)) return false; + + return true; + } + + private double getRelativeRateChange(JudgementResult result) + { + if (!result.IsHit) + return rate_change_on_miss; + + double prevEndTime = precedingEndTimes[result.HitObject]; + return Math.Clamp( + (result.HitObject.GetEndTime() - prevEndTime) / (result.TimeAbsolute - prevEndTime), + min_allowable_rate_change, + max_allowable_rate_change + ); + } + + /// + /// Update based on the values in . + /// + private void updateTargetRate() + { + // Compare values in recentRates to see how consistent the player's speed is + // If the player hits half of the notes too fast and the other half too slow: Abs(consistency) = 0 + // If the player hits all their notes too fast or too slow: Abs(consistency) = recent_rate_count - 1 + int consistency = 0; + + for (int i = 1; i < recentRates.Count; i++) + { + consistency += Math.Sign(recentRates[i] - recentRates[i - 1]); + } + + // Scale the rate adjustment based on consistency + targetRate = Interpolation.Lerp(targetRate, recentRates.Average(), Math.Abs(consistency) / (recent_rate_count - 1d)); + } + } +} diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index e66650f7b4..ebe18f2188 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Mods public double ApplyToRate(double time, double rate) => rate * SpeedChange.Value; - public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp) }; + public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed) }; public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x"; } diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index b5cd64dafa..b6b2decede 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] public abstract BindableBool AdjustPitch { get; } - public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust) }; + public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModAdaptiveSpeed) }; public override string SettingDescription => $"{InitialRate.Value:N2}x to {FinalRate.Value:N2}x";