diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
index a5f6468f17..e5e9769081 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
@@ -2,9 +2,13 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Collections.Generic;
+using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
+using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
@@ -24,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
/// - and how easily they can be cheesed.
///
///
- public static double EvaluateDifficultyOf(DifficultyHitObject current)
+ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList mods)
{
if (current.BaseObject is Spinner)
return 0;
@@ -56,6 +60,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier;
+ if (mods.OfType().Any())
+ distanceBonus = 0;
+
// Base difficulty with all bonuses
double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
index eb3a44c07e..78a1c95066 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
@@ -69,6 +69,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
speedRating = 0.0;
flashlightRating *= 0.7;
}
+ else if (mods.Any(h => h is OsuModAutopilot))
+ {
+ speedRating *= 0.5;
+ aimRating = 0.0;
+ flashlightRating *= 0.4;
+ }
double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating);
double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating);
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
index e6600d1ce0..8f09147c9f 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
@@ -136,6 +136,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attributes)
{
+ if (score.Mods.Any(h => h is OsuModAutopilot))
+ return 0.0;
+
double aimDifficulty = attributes.AimDifficulty;
if (attributes.SliderCount > 0 && attributes.AimDifficultSliderCount > 0)
@@ -218,6 +221,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (attributes.ApproachRate > 10.33)
approachRateFactor = 0.3 * (attributes.ApproachRate - 10.33);
+ if (score.Mods.Any(h => h is OsuModAutopilot))
+ approachRateFactor = 0.0;
+
speedValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR.
if (score.Mods.Any(m => m is OsuModBlinds))
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
index d107cb75b7..10d5c1e27e 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
@@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
protected override double StrainValueAt(DifficultyHitObject current)
{
currentStrain *= strainDecay(((OsuDifficultyHitObject)current).StrainTime);
- currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current) * skillMultiplier;
+ currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier;
currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
index 09d6540f72..ba247c68d4 100644
--- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
@@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
- [TestCase(3.0920212594351191d, 200, "diffcalc-test")]
- [TestCase(3.0920212594351191d, 200, "diffcalc-test-strong")]
+ [TestCase(3.0950934814938953d, 200, "diffcalc-test")]
+ [TestCase(3.0950934814938953d, 200, "diffcalc-test-strong")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
- [TestCase(4.0789820318081444d, 200, "diffcalc-test")]
- [TestCase(4.0789820318081444d, 200, "diffcalc-test-strong")]
+ [TestCase(4.0839365008715403d, 200, "diffcalc-test")]
+ [TestCase(4.0839365008715403d, 200, "diffcalc-test-strong")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime());
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs
new file mode 100644
index 0000000000..3a294f7123
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs
@@ -0,0 +1,149 @@
+// 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 osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Utils;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data;
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
+{
+ public class RhythmEvaluator
+ {
+ ///
+ /// Multiplier for a given denominator term.
+ ///
+ private static double termPenalty(double ratio, int denominator, double power, double multiplier)
+ {
+ return -multiplier * Math.Pow(Math.Cos(denominator * Math.PI * ratio), power);
+ }
+
+ ///
+ /// Calculates the difficulty of a given ratio using a combination of periodic penalties and bonuses.
+ ///
+ private static double ratioDifficulty(double ratio, int terms = 8)
+ {
+ double difficulty = 0;
+
+ for (int i = 1; i <= terms; ++i)
+ {
+ difficulty += termPenalty(ratio, i, 2, 1);
+ }
+
+ difficulty += terms;
+
+ // Give bonus to near-1 ratios
+ difficulty += DifficultyCalculationUtils.BellCurve(ratio, 1, 0.7);
+
+ // Penalize ratios that are VERY near 1
+ difficulty -= DifficultyCalculationUtils.BellCurve(ratio, 1, 0.5);
+
+ return difficulty / Math.Sqrt(8);
+ }
+
+ ///
+ /// Determines if the changes in hit object intervals is consistent based on a given threshold.
+ ///
+ private static double repeatedIntervalPenalty(SameRhythmHitObjects sameRhythmHitObjects, double hitWindow, double threshold = 0.1)
+ {
+ double longIntervalPenalty = sameInterval(sameRhythmHitObjects, 3);
+
+ double shortIntervalPenalty = sameRhythmHitObjects.Children.Count < 6
+ ? sameInterval(sameRhythmHitObjects, 4)
+ : 1.0; // Returns a non-penalty if there are 6 or more notes within an interval.
+
+ // Scale penalties dynamically based on hit object duration relative to hitWindow.
+ double penaltyScaling = Math.Max(1 - sameRhythmHitObjects.Duration / (hitWindow * 2), 0.5);
+
+ return Math.Min(longIntervalPenalty, shortIntervalPenalty) * penaltyScaling;
+
+ double sameInterval(SameRhythmHitObjects startObject, int intervalCount)
+ {
+ List intervals = new List();
+ var currentObject = startObject;
+
+ for (int i = 0; i < intervalCount && currentObject != null; i++)
+ {
+ intervals.Add(currentObject.HitObjectInterval);
+ currentObject = currentObject.Previous;
+ }
+
+ intervals.RemoveAll(interval => interval == null);
+
+ if (intervals.Count < intervalCount)
+ return 1.0; // No penalty if there aren't enough valid intervals.
+
+ for (int i = 0; i < intervals.Count; i++)
+ {
+ for (int j = i + 1; j < intervals.Count; j++)
+ {
+ double ratio = intervals[i]!.Value / intervals[j]!.Value;
+ if (Math.Abs(1 - ratio) <= threshold) // If any two intervals are similar, apply a penalty.
+ return 0.3;
+ }
+ }
+
+ return 1.0; // No penalty if all intervals are different.
+ }
+ }
+
+ private static double evaluateDifficultyOf(SameRhythmHitObjects sameRhythmHitObjects, double hitWindow)
+ {
+ double intervalDifficulty = ratioDifficulty(sameRhythmHitObjects.HitObjectIntervalRatio);
+ double? previousInterval = sameRhythmHitObjects.Previous?.HitObjectInterval;
+
+ // If a previous interval exists and there are multiple hit objects in the sequence:
+ if (previousInterval != null && sameRhythmHitObjects.Children.Count > 1)
+ {
+ double expectedDurationFromPrevious = (double)previousInterval * sameRhythmHitObjects.Children.Count;
+ double durationDifference = sameRhythmHitObjects.Duration - expectedDurationFromPrevious;
+
+ if (durationDifference > 0)
+ {
+ intervalDifficulty *= DifficultyCalculationUtils.Logistic(
+ durationDifference / hitWindow,
+ midpointOffset: 0.7,
+ multiplier: 1.5,
+ maxValue: 1);
+ }
+ }
+
+ // Apply consistency penalty.
+ intervalDifficulty *= repeatedIntervalPenalty(sameRhythmHitObjects, hitWindow);
+
+ // Penalise patterns that can be hit within a single hit window.
+ intervalDifficulty *= DifficultyCalculationUtils.Logistic(
+ sameRhythmHitObjects.Duration / hitWindow,
+ midpointOffset: 0.6,
+ multiplier: 1,
+ maxValue: 1);
+
+ return Math.Pow(intervalDifficulty, 0.75);
+ }
+
+ private static double evaluateDifficultyOf(SamePatterns samePatterns)
+ {
+ return ratioDifficulty(samePatterns.IntervalRatio);
+ }
+
+ ///
+ /// Evaluate the difficulty of a hitobject considering its interval change.
+ ///
+ public static double EvaluateDifficultyOf(DifficultyHitObject hitObject, double hitWindow)
+ {
+ TaikoDifficultyHitObjectRhythm rhythm = ((TaikoDifficultyHitObject)hitObject).Rhythm;
+ double difficulty = 0.0d;
+
+ if (rhythm.SameRhythmHitObjects?.FirstHitObject == hitObject) // Difficulty for SameRhythmHitObjects
+ difficulty += evaluateDifficultyOf(rhythm.SameRhythmHitObjects, hitWindow);
+
+ if (rhythm.SamePatterns?.FirstHitObject == hitObject) // Difficulty for SamePatterns
+ difficulty += 0.5 * evaluateDifficultyOf(rhythm.SamePatterns);
+
+ return difficulty;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SamePatterns.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SamePatterns.cs
new file mode 100644
index 0000000000..50839c4561
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SamePatterns.cs
@@ -0,0 +1,55 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
+{
+ ///
+ /// Represents grouped by their 's interval.
+ ///
+ public class SamePatterns : SameRhythm
+ {
+ public SamePatterns? Previous { get; private set; }
+
+ ///
+ /// The between children within this group.
+ /// If there is only one child, this will have the value of the first child's .
+ ///
+ public double ChildrenInterval => Children.Count > 1 ? Children[1].Interval : Children[0].Interval;
+
+ ///
+ /// The ratio of between this and the previous . In the
+ /// case where there is no previous , this will have a value of 1.
+ ///
+ public double IntervalRatio => ChildrenInterval / Previous?.ChildrenInterval ?? 1.0d;
+
+ public TaikoDifficultyHitObject FirstHitObject => Children[0].FirstHitObject;
+
+ public IEnumerable AllHitObjects => Children.SelectMany(child => child.Children);
+
+ private SamePatterns(SamePatterns? previous, List data, ref int i)
+ : base(data, ref i, 5)
+ {
+ Previous = previous;
+
+ foreach (TaikoDifficultyHitObject hitObject in AllHitObjects)
+ {
+ hitObject.Rhythm.SamePatterns = this;
+ }
+ }
+
+ public static void GroupPatterns(List data)
+ {
+ List samePatterns = new List();
+
+ // Index does not need to be incremented, as it is handled within the SameRhythm constructor.
+ for (int i = 0; i < data.Count;)
+ {
+ SamePatterns? previous = samePatterns.Count > 0 ? samePatterns[^1] : null;
+ samePatterns.Add(new SamePatterns(previous, data, ref i));
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythm.cs
new file mode 100644
index 0000000000..b1ca22595b
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythm.cs
@@ -0,0 +1,73 @@
+// 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;
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
+{
+ ///
+ /// A base class for grouping s by their interval. In edges where an interval change
+ /// occurs, the is added to the group with the smaller interval.
+ ///
+ public abstract class SameRhythm
+ where ChildType : IHasInterval
+ {
+ public IReadOnlyList Children { get; private set; }
+
+ ///
+ /// Determines if the intervals between two child objects are within a specified margin of error,
+ /// indicating that the intervals are effectively "flat" or consistent.
+ ///
+ private bool isFlat(ChildType current, ChildType previous, double marginOfError)
+ {
+ return Math.Abs(current.Interval - previous.Interval) <= marginOfError;
+ }
+
+ ///
+ /// Create a new from a list of s, and add
+ /// them to the list until the end of the group.
+ ///
+ /// The list of s.
+ ///
+ /// Index in to start adding children. This will be modified and should be passed into
+ /// the next 's constructor.
+ ///
+ ///
+ /// The margin of error for the interval, within of which no interval change is considered to have occured.
+ ///
+ protected SameRhythm(List data, ref int i, double marginOfError)
+ {
+ List children = new List();
+ Children = children;
+ children.Add(data[i]);
+ i++;
+
+ for (; i < data.Count - 1; i++)
+ {
+ // An interval change occured, add the current data if the next interval is larger.
+ if (!isFlat(data[i], data[i + 1], marginOfError))
+ {
+ if (data[i + 1].Interval > data[i].Interval + marginOfError)
+ {
+ children.Add(data[i]);
+ i++;
+ }
+
+ return;
+ }
+
+ // No interval change occured
+ children.Add(data[i]);
+ }
+
+ // Check if the last two objects in the data form a "flat" rhythm pattern within the specified margin of error.
+ // If true, add the current object to the group and increment the index to process the next object.
+ if (data.Count > 2 && isFlat(data[^1], data[^2], marginOfError))
+ {
+ children.Add(data[i]);
+ i++;
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjects.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjects.cs
new file mode 100644
index 0000000000..0ccc6da026
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjects.cs
@@ -0,0 +1,94 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using osu.Game.Rulesets.Difficulty.Preprocessing;
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
+{
+ ///
+ /// Represents a group of s with no rhythm variation.
+ ///
+ public class SameRhythmHitObjects : SameRhythm, IHasInterval
+ {
+ public TaikoDifficultyHitObject FirstHitObject => Children[0];
+
+ public SameRhythmHitObjects? Previous;
+
+ ///
+ /// of the first hit object.
+ ///
+ public double StartTime => Children[0].StartTime;
+
+ ///
+ /// The interval between the first and final hit object within this group.
+ ///
+ public double Duration => Children[^1].StartTime - Children[0].StartTime;
+
+ ///
+ /// The interval in ms of each hit object in this . This is only defined if there is
+ /// more than two hit objects in this .
+ ///
+ public double? HitObjectInterval;
+
+ ///
+ /// The ratio of between this and the previous . In the
+ /// case where one or both of the is undefined, this will have a value of 1.
+ ///
+ public double HitObjectIntervalRatio = 1;
+
+ ///
+ /// The interval between the of this and the previous .
+ ///
+ public double Interval { get; private set; } = double.PositiveInfinity;
+
+ public SameRhythmHitObjects(SameRhythmHitObjects? previous, List data, ref int i)
+ : base(data, ref i, 5)
+ {
+ Previous = previous;
+
+ foreach (var hitObject in Children)
+ {
+ hitObject.Rhythm.SameRhythmHitObjects = this;
+
+ // Pass the HitObjectInterval to each child.
+ hitObject.HitObjectInterval = HitObjectInterval;
+ }
+
+ calculateIntervals();
+ }
+
+ public static List GroupHitObjects(List data)
+ {
+ List flatPatterns = new List();
+
+ // Index does not need to be incremented, as it is handled within SameRhythm's constructor.
+ for (int i = 0; i < data.Count;)
+ {
+ SameRhythmHitObjects? previous = flatPatterns.Count > 0 ? flatPatterns[^1] : null;
+ flatPatterns.Add(new SameRhythmHitObjects(previous, data, ref i));
+ }
+
+ return flatPatterns;
+ }
+
+ private void calculateIntervals()
+ {
+ // Calculate the average interval between hitobjects, or null if there are fewer than two.
+ HitObjectInterval = Children.Count < 2 ? null : (Children[^1].StartTime - Children[0].StartTime) / (Children.Count - 1);
+
+ // If both the current and previous intervals are available, calculate the ratio.
+ if (Previous?.HitObjectInterval != null && HitObjectInterval != null)
+ {
+ HitObjectIntervalRatio = HitObjectInterval.Value / Previous.HitObjectInterval.Value;
+ }
+
+ if (Previous == null)
+ {
+ return;
+ }
+
+ Interval = StartTime - Previous.StartTime;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/IHasInterval.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/IHasInterval.cs
new file mode 100644
index 0000000000..8f3917cbde
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/IHasInterval.cs
@@ -0,0 +1,13 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm
+{
+ ///
+ /// The interface for hitobjects that provide an interval value.
+ ///
+ public interface IHasInterval
+ {
+ double Interval { get; }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs
index a273d7e2ea..beb7bfe5f6 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs
@@ -1,35 +1,98 @@
// 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.Linq;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data;
+
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm
{
///
- /// Represents a rhythm change in a taiko map.
+ /// Stores rhythm data for a .
///
public class TaikoDifficultyHitObjectRhythm
{
///
- /// The difficulty multiplier associated with this rhythm change.
+ /// The group of hit objects with consistent rhythm that this object belongs to.
///
- public readonly double Difficulty;
+ public SameRhythmHitObjects? SameRhythmHitObjects;
///
- /// The ratio of current
- /// to previous for the rhythm change.
+ /// The larger pattern of rhythm groups that this object is part of.
+ ///
+ public SamePatterns? SamePatterns;
+
+ ///
+ /// The ratio of current
+ /// to previous for the rhythm change.
/// A above 1 indicates a slow-down; a below 1 indicates a speed-up.
///
public readonly double Ratio;
+ ///
+ /// List of most common rhythm changes in taiko maps. Based on how each object's interval compares to the previous object.
+ ///
+ ///
+ /// The general guidelines for the values are:
+ ///
+ /// - rhythm changes with ratio closer to 1 (that are not 1) are harder to play,
+ /// - speeding up is generally harder than slowing down (with exceptions of rhythm changes requiring a hand switch).
+ ///
+ ///
+ private static readonly TaikoDifficultyHitObjectRhythm[] common_rhythms =
+ {
+ new TaikoDifficultyHitObjectRhythm(1, 1),
+ new TaikoDifficultyHitObjectRhythm(2, 1),
+ new TaikoDifficultyHitObjectRhythm(1, 2),
+ new TaikoDifficultyHitObjectRhythm(3, 1),
+ new TaikoDifficultyHitObjectRhythm(1, 3),
+ new TaikoDifficultyHitObjectRhythm(3, 2),
+ new TaikoDifficultyHitObjectRhythm(2, 3),
+ new TaikoDifficultyHitObjectRhythm(5, 4),
+ new TaikoDifficultyHitObjectRhythm(4, 5)
+ };
+
+ ///
+ /// Initialises a new instance of s,
+ /// calculating the closest rhythm change and its associated difficulty for the current hit object.
+ ///
+ /// The current being processed.
+ public TaikoDifficultyHitObjectRhythm(TaikoDifficultyHitObject current)
+ {
+ var previous = current.Previous(0);
+
+ if (previous == null)
+ {
+ Ratio = 1;
+ return;
+ }
+
+ TaikoDifficultyHitObjectRhythm closestRhythm = getClosestRhythm(current.DeltaTime, previous.DeltaTime);
+ Ratio = closestRhythm.Ratio;
+ }
+
///
/// Creates an object representing a rhythm change.
///
/// The numerator for .
/// The denominator for
- /// The difficulty multiplier associated with this rhythm change.
- public TaikoDifficultyHitObjectRhythm(int numerator, int denominator, double difficulty)
+ private TaikoDifficultyHitObjectRhythm(int numerator, int denominator)
{
Ratio = numerator / (double)denominator;
- Difficulty = difficulty;
+ }
+
+ ///
+ /// Determines the closest rhythm change from that matches the timing ratio
+ /// between the current and previous intervals.
+ ///
+ /// The time difference between the current hit object and the previous one.
+ /// The time difference between the previous hit object and the one before it.
+ /// The closest matching rhythm from .
+ private TaikoDifficultyHitObjectRhythm getClosestRhythm(double currentDeltaTime, double previousDeltaTime)
+ {
+ double ratio = currentDeltaTime / previousDeltaTime;
+ return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First();
}
}
}
+
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs
index e741e4c9e7..dfcd08ed94 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs
@@ -1,7 +1,6 @@
// 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.Game.Rulesets.Difficulty.Preprocessing;
@@ -15,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
///
/// Represents a single hit object in taiko difficulty calculation.
///
- public class TaikoDifficultyHitObject : DifficultyHitObject
+ public class TaikoDifficultyHitObject : DifficultyHitObject, IHasInterval
{
///
/// The list of all of the same colour as this in the beatmap.
@@ -42,6 +41,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
///
public readonly TaikoDifficultyHitObjectRhythm Rhythm;
+ ///
+ /// The interval between this hit object and the surrounding hit objects in its rhythm group.
+ ///
+ public double? HitObjectInterval { get; set; }
+
///
/// Colour data for this hit object. This is used by colour evaluator to calculate colour difficulty, but can be used
/// by other skills in the future.
@@ -58,6 +62,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
///
public double CurrentSliderVelocity;
+ public double Interval => DeltaTime;
+
///
/// Creates a new difficulty hit object.
///
@@ -81,7 +87,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
// Create the Colour object, its properties should be filled in by TaikoDifficultyPreprocessor
Colour = new TaikoDifficultyHitObjectColour();
- Rhythm = getClosestRhythm(lastObject, lastLastObject, clockRate);
+
+ // Create a Rhythm object, its properties are filled in by TaikoDifficultyHitObjectRhythm
+ Rhythm = new TaikoDifficultyHitObjectRhythm(this);
switch ((hitObject as Hit)?.Type)
{
@@ -105,43 +113,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
}
}
- ///
- /// List of most common rhythm changes in taiko maps.
- ///
- ///
- /// The general guidelines for the values are:
- ///
- /// - rhythm changes with ratio closer to 1 (that are not 1) are harder to play,
- /// - speeding up is generally harder than slowing down (with exceptions of rhythm changes requiring a hand switch).
- ///
- ///
- private static readonly TaikoDifficultyHitObjectRhythm[] common_rhythms =
- {
- new TaikoDifficultyHitObjectRhythm(1, 1, 0.0),
- new TaikoDifficultyHitObjectRhythm(2, 1, 0.3),
- new TaikoDifficultyHitObjectRhythm(1, 2, 0.5),
- new TaikoDifficultyHitObjectRhythm(3, 1, 0.3),
- new TaikoDifficultyHitObjectRhythm(1, 3, 0.35),
- new TaikoDifficultyHitObjectRhythm(3, 2, 0.6), // purposefully higher (requires hand switch in full alternating gameplay style)
- new TaikoDifficultyHitObjectRhythm(2, 3, 0.4),
- new TaikoDifficultyHitObjectRhythm(5, 4, 0.5),
- new TaikoDifficultyHitObjectRhythm(4, 5, 0.7)
- };
-
- ///
- /// Returns the closest rhythm change from required to hit this object.
- ///
- /// The gameplay preceding this one.
- /// The gameplay preceding .
- /// The rate of the gameplay clock.
- private TaikoDifficultyHitObjectRhythm getClosestRhythm(HitObject lastObject, HitObject lastLastObject, double clockRate)
- {
- double prevLength = (lastObject.StartTime - lastLastObject.StartTime) / clockRate;
- double ratio = DeltaTime / prevLength;
-
- return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First();
- }
-
public TaikoDifficultyHitObject? PreviousMono(int backwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex - (backwardsIndex + 1));
public TaikoDifficultyHitObject? NextMono(int forwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex + (forwardsIndex + 1));
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs
index e76af13686..4fe1ea693e 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs
@@ -1,13 +1,11 @@
// 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 osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
+using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
-using osu.Game.Rulesets.Taiko.Objects;
-using osu.Game.Utils;
+using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
{
@@ -16,158 +14,25 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
///
public class Rhythm : StrainDecaySkill
{
- protected override double SkillMultiplier => 10;
- protected override double StrainDecayBase => 0;
+ protected override double SkillMultiplier => 1.0;
+ protected override double StrainDecayBase => 0.4;
- ///
- /// The note-based decay for rhythm strain.
- ///
- ///
- /// is not used here, as it's time- and not note-based.
- ///
- private const double strain_decay = 0.96;
+ private readonly double greatHitWindow;
- ///
- /// Maximum number of entries in .
- ///
- private const int rhythm_history_max_length = 8;
-
- ///
- /// Contains the last changes in note sequence rhythms.
- ///
- private readonly LimitedCapacityQueue rhythmHistory = new LimitedCapacityQueue(rhythm_history_max_length);
-
- ///
- /// Contains the rolling rhythm strain.
- /// Used to apply per-note decay.
- ///
- private double currentStrain;
-
- ///
- /// Number of notes since the last rhythm change has taken place.
- ///
- private int notesSinceRhythmChange;
-
- public Rhythm(Mod[] mods)
+ public Rhythm(Mod[] mods, double greatHitWindow)
: base(mods)
{
+ this.greatHitWindow = greatHitWindow;
}
protected override double StrainValueOf(DifficultyHitObject current)
{
- // drum rolls and swells are exempt.
- if (!(current.BaseObject is Hit))
- {
- resetRhythmAndStrain();
- return 0.0;
- }
+ double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current, greatHitWindow);
- currentStrain *= strain_decay;
+ // To prevent abuse of exceedingly long intervals between awkward rhythms, we penalise its difficulty.
+ difficulty *= DifficultyCalculationUtils.Logistic(current.DeltaTime, 350, -1 / 25.0, 0.5) + 0.5;
- TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current;
- notesSinceRhythmChange += 1;
-
- // rhythm difficulty zero (due to rhythm not changing) => no rhythm strain.
- if (hitObject.Rhythm.Difficulty == 0.0)
- {
- return 0.0;
- }
-
- double objectStrain = hitObject.Rhythm.Difficulty;
-
- objectStrain *= repetitionPenalties(hitObject);
- objectStrain *= patternLengthPenalty(notesSinceRhythmChange);
- objectStrain *= speedPenalty(hitObject.DeltaTime);
-
- // careful - needs to be done here since calls above read this value
- notesSinceRhythmChange = 0;
-
- currentStrain += objectStrain;
- return currentStrain;
- }
-
- ///
- /// Returns a penalty to apply to the current hit object caused by repeating rhythm changes.
- ///
- ///
- /// Repetitions of more recent patterns are associated with a higher penalty.
- ///
- /// The current hit object being considered.
- private double repetitionPenalties(TaikoDifficultyHitObject hitObject)
- {
- double penalty = 1;
-
- rhythmHistory.Enqueue(hitObject);
-
- for (int mostRecentPatternsToCompare = 2; mostRecentPatternsToCompare <= rhythm_history_max_length / 2; mostRecentPatternsToCompare++)
- {
- for (int start = rhythmHistory.Count - mostRecentPatternsToCompare - 1; start >= 0; start--)
- {
- if (!samePattern(start, mostRecentPatternsToCompare))
- continue;
-
- int notesSince = hitObject.Index - rhythmHistory[start].Index;
- penalty *= repetitionPenalty(notesSince);
- break;
- }
- }
-
- return penalty;
- }
-
- ///
- /// Determines whether the rhythm change pattern starting at is a repeat of any of the
- /// .
- ///
- private bool samePattern(int start, int mostRecentPatternsToCompare)
- {
- for (int i = 0; i < mostRecentPatternsToCompare; i++)
- {
- if (rhythmHistory[start + i].Rhythm != rhythmHistory[rhythmHistory.Count - mostRecentPatternsToCompare + i].Rhythm)
- return false;
- }
-
- return true;
- }
-
- ///
- /// Calculates a single rhythm repetition penalty.
- ///
- /// Number of notes since the last repetition of a rhythm change.
- private static double repetitionPenalty(int notesSince) => Math.Min(1.0, 0.032 * notesSince);
-
- ///
- /// Calculates a penalty based on the number of notes since the last rhythm change.
- /// Both rare and frequent rhythm changes are penalised.
- ///
- /// Number of notes since the last rhythm change.
- private static double patternLengthPenalty(int patternLength)
- {
- double shortPatternPenalty = Math.Min(0.15 * patternLength, 1.0);
- double longPatternPenalty = Math.Clamp(2.5 - 0.15 * patternLength, 0.0, 1.0);
- return Math.Min(shortPatternPenalty, longPatternPenalty);
- }
-
- ///
- /// Calculates a penalty for objects that do not require alternating hands.
- ///
- /// Time (in milliseconds) since the last hit object.
- private double speedPenalty(double deltaTime)
- {
- if (deltaTime < 80) return 1;
- if (deltaTime < 210) return Math.Max(0, 1.4 - 0.005 * deltaTime);
-
- resetRhythmAndStrain();
- return 0.0;
- }
-
- ///
- /// Resets the rolling strain value and counter.
- ///
- private void resetRhythmAndStrain()
- {
- currentStrain = 0.0;
- notesSinceRhythmChange = 0;
+ return difficulty;
}
}
}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
index d3cdb379d5..ef729e1f07 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
@@ -10,18 +10,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
public class TaikoDifficultyAttributes : DifficultyAttributes
{
- ///
- /// The difficulty corresponding to the stamina skill.
- ///
- [JsonProperty("stamina_difficulty")]
- public double StaminaDifficulty { get; set; }
-
- ///
- /// The ratio of stamina difficulty from mono-color (single colour) streams to total stamina difficulty.
- ///
- [JsonProperty("mono_stamina_factor")]
- public double MonoStaminaFactor { get; set; }
-
///
/// The difficulty corresponding to the rhythm skill.
///
@@ -40,8 +28,20 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("colour_difficulty")]
public double ColourDifficulty { get; set; }
- [JsonProperty("rhythm_difficult_strains")]
- public double RhythmTopStrains { get; set; }
+ ///
+ /// The difficulty corresponding to the stamina skill.
+ ///
+ [JsonProperty("stamina_difficulty")]
+ public double StaminaDifficulty { get; set; }
+
+ ///
+ /// The ratio of stamina difficulty from mono-color (single colour) streams to total stamina difficulty.
+ ///
+ [JsonProperty("mono_stamina_factor")]
+ public double MonoStaminaFactor { get; set; }
+
+ [JsonProperty("reading_difficult_strains")]
+ public double ReadingTopStrains { get; set; }
[JsonProperty("colour_difficult_strains")]
public double ColourTopStrains { get; set; }
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
index 0d6ecb8d3e..f8ff6f6065 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
@@ -14,6 +14,7 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Reading;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data;
using osu.Game.Rulesets.Taiko.Difficulty.Skills;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Scoring;
@@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public class TaikoDifficultyCalculator : DifficultyCalculator
{
private const double difficulty_multiplier = 0.084375;
- private const double rhythm_skill_multiplier = 0.200 * difficulty_multiplier;
+ private const double rhythm_skill_multiplier = 1.24 * difficulty_multiplier;
private const double reading_skill_multiplier = 0.100 * difficulty_multiplier;
private const double colour_skill_multiplier = 0.375 * difficulty_multiplier;
private const double stamina_skill_multiplier = 0.375 * difficulty_multiplier;
@@ -37,9 +38,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
{
+ HitWindows hitWindows = new HitWindows();
+ hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
+
return new Skill[]
{
- new Rhythm(mods),
+ new Rhythm(mods, hitWindows.WindowFor(HitResult.Great) / clockRate),
new Reading(mods),
new Colour(mods),
new Stamina(mods, false),
@@ -57,6 +61,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
+ var hitWindows = new HitWindows();
+ hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
+
var difficultyHitObjects = new List();
var centreObjects = new List();
var rimObjects = new List();
@@ -79,7 +86,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
));
}
+ var groupedHitObjects = SameRhythmHitObjects.GroupHitObjects(noteObjects);
+
TaikoColourDifficultyPreprocessor.ProcessAndAssign(difficultyHitObjects);
+ SamePatterns.GroupPatterns(groupedHitObjects);
bpmLoader.ProcessEffectiveBPM(beatmap.ControlPointInfo, clockRate);
return difficultyHitObjects;
@@ -105,8 +115,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5);
- double rhythmDifficultStrains = rhythm.CountTopWeightedStrains();
double colourDifficultStrains = colour.CountTopWeightedStrains();
+ double readingDifficultStrains = reading.CountTopWeightedStrains();
double staminaDifficultStrains = stamina.CountTopWeightedStrains();
double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, isRelax);
@@ -134,9 +144,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
ColourDifficulty = colourRating,
StaminaDifficulty = staminaRating,
MonoStaminaFactor = monoStaminaFactor,
- StaminaTopStrains = staminaDifficultStrains,
- RhythmTopStrains = rhythmDifficultStrains,
+ ReadingTopStrains = readingDifficultStrains,
ColourTopStrains = colourDifficultStrains,
+ StaminaTopStrains = staminaDifficultStrains,
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate,
MaxCombo = beatmap.GetMaxCombo(),
diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs
index 055d8a458b..497a1f8234 100644
--- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs
+++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs
@@ -56,6 +56,16 @@ namespace osu.Game.Rulesets.Difficulty.Utils
/// The p-norm of the vector.
public static double Norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p);
+ ///
+ /// Calculates a Gaussian-based bell curve function (https://en.wikipedia.org/wiki/Gaussian_function)
+ ///
+ /// Value to calculate the function for
+ /// The mean (center) of the bell curve
+ /// The width (spread) of the curve
+ /// Multiplier to adjust the curve's height
+ /// The output of the bell curve function of
+ public static double BellCurve(double x, double mean, double width, double multiplier = 1.0) => multiplier * Math.Exp(Math.E * -(Math.Pow(x - mean, 2) / Math.Pow(width, 2)));
+
///
/// Smootherstep function (https://en.wikipedia.org/wiki/Smoothstep#Variations)
///