1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-05 03:03:21 +08:00
This commit is contained in:
Jay Lawton 2024-12-03 14:17:55 +00:00 committed by GitHub
commit fbc3000765
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 806 additions and 246 deletions

View File

@ -0,0 +1,45 @@
// 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.
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
{
public static class ReadingEvaluator
{
private const double high_sv_multiplier = 1.0;
/// <summary>
/// Calculates the influence of higher slider velocities on hitobject difficulty.
/// The bonus is determined based on the EffectiveBPM, shifting within a defined range
/// between the upper and lower boundaries to reflect how increased slider velocity impacts difficulty.
/// </summary>
/// <param name="noteObject">The hit object to evaluate.</param>
/// <returns>The reading difficulty value for the given hit object.</returns>
public static double EvaluateDifficultyOf(TaikoDifficultyHitObject noteObject)
{
double effectiveBPM = noteObject.EffectiveBPM;
const double velocity_max = 640;
const double velocity_min = 480;
const double center = (velocity_max + velocity_min) / 2;
const double range = velocity_max - velocity_min;
return high_sv_multiplier * DifficultyCalculationUtils.Logistic(effectiveBPM, center, 1.0 / (range / 10));
}
/// <summary>
/// Calculates the object density based on the DeltaTime, EffectiveBPM, and CurrentSliderVelocity.
/// </summary>
/// <param name="noteObject">The current noteObject to evaluate.</param>
/// <returns>The calculated object density.</returns>
public static double CalculateObjectDensity(TaikoDifficultyHitObject noteObject)
{
double objectDensity = 50 * DifficultyCalculationUtils.Logistic(noteObject.DeltaTime, 200, 1.0 / 300);
return 1 - DifficultyCalculationUtils.Logistic(noteObject.EffectiveBPM, objectDensity, 1.0 / 240);
}
}
}

View File

@ -0,0 +1,137 @@
// 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.
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
{
/// <summary>
/// Multiplier for a given denominator term.
/// </summary>
private static double termPenalty(double ratio, int denominator, double power, double multiplier)
{
return -multiplier * Math.Pow(Math.Cos(denominator * Math.PI * ratio), power);
}
/// <summary>
/// Calculates the difficulty of a given ratio using a combination of periodic penalties and bonuses.
/// </summary>
private static double ratioDifficulty(double ratio, int terms = 8)
{
// Sum of n = 8 terms of periodic penalty.
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.5);
// Penalize ratios that are VERY near 1
difficulty -= DifficultyCalculationUtils.BellCurve(ratio, 1, 0.3);
return difficulty / Math.Sqrt(8);
}
/// <summary>
/// Determines if the pattern of hit object intervals is consistent based on a given threshold.
/// </summary>
private static bool isConsistentPattern(EvenHitObjects evenHitObjects, double threshold = 0.1)
{
// Collect the last 4 intervals (current and the last 3 previous).
List<double?> intervals = new List<double?>();
var currentObject = evenHitObjects;
const int interval_count = 4;
for (int i = 0; i < interval_count && currentObject != null; i++)
{
intervals.Add(currentObject.HitObjectInterval);
currentObject = currentObject.Previous;
}
intervals.RemoveAll(interval => interval == null);
// If there are fewer than 4 valid intervals, skip the consistency check.
if (intervals.Count < interval_count)
return false;
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, return true.
return true;
}
}
// No similar intervals were found.
return false;
}
private static double evaluateDifficultyOf(EvenHitObjects evenHitObjects, double hitWindow)
{
double intervalDifficulty = ratioDifficulty(evenHitObjects.HitObjectIntervalRatio);
double? previousInterval = evenHitObjects.Previous?.HitObjectInterval;
// If a previous interval exists and there are multiple hit objects in the sequence:
if (previousInterval != null && evenHitObjects.Children.Count > 1)
{
double expectedDurationFromPrevious = (double)previousInterval * evenHitObjects.Children.Count;
double durationDifference = Math.Abs(evenHitObjects.Duration - expectedDurationFromPrevious);
intervalDifficulty *= DifficultyCalculationUtils.Logistic(
durationDifference / hitWindow,
midpointOffset: 0.5,
multiplier: 1.5,
maxValue: 1);
}
// Penalise regular intervals within the last four intervals.
if (isConsistentPattern(evenHitObjects))
{
intervalDifficulty *= 0.4;
}
// Penalise patterns that can be hit within a single hit window.
intervalDifficulty *= DifficultyCalculationUtils.Logistic(
evenHitObjects.Duration / hitWindow,
midpointOffset: 0.5,
multiplier: 1,
maxValue: 1);
return intervalDifficulty;
}
private static double evaluateDifficultyOf(EvenPatterns evenPatterns)
{
return ratioDifficulty(evenPatterns.IntervalRatio);
}
public static double EvaluateDifficultyOf(DifficultyHitObject hitObject, double hitWindow)
{
TaikoDifficultyHitObjectRhythm rhythm = ((TaikoDifficultyHitObject)hitObject).Rhythm;
double difficulty = 0.0d;
if (rhythm.EvenHitObjects?.FirstHitObject == hitObject) // Difficulty for EvenHitObjects
difficulty += evaluateDifficultyOf(rhythm.EvenHitObjects, hitWindow);
if (rhythm.EvenPatterns?.FirstHitObject == hitObject) // Difficulty for EvenPatterns
difficulty += evaluateDifficultyOf(rhythm.EvenPatterns) * rhythm.Difficulty;
return difficulty;
}
}
}

View File

@ -1,9 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
using System.Collections.Generic;
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data
{ {

View File

@ -0,0 +1,50 @@
// 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.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Reading
{
public class EffectiveBPMPreprocessor
{
private readonly IList<TaikoDifficultyHitObject> noteObjects;
private readonly double globalSliderVelocity;
public EffectiveBPMPreprocessor(IBeatmap beatmap, List<TaikoDifficultyHitObject> noteObjects)
{
this.noteObjects = noteObjects;
globalSliderVelocity = beatmap.Difficulty.SliderMultiplier;
}
/// <summary>
/// Calculates and sets the effective BPM and slider velocity for each note object, considering clock rate and scroll speed.
/// </summary>
public void ProcessEffectiveBPM(ControlPointInfo controlPointInfo, double clockRate)
{
foreach (var currentNoteObject in noteObjects)
{
double startTime = currentNoteObject.StartTime * clockRate;
// Retrieve the timing point at the note's start time
TimingControlPoint currentControlPoint = controlPointInfo.TimingPointAt(startTime);
// Calculate the slider velocity at the note's start time.
double currentSliderVelocity = calculateSliderVelocity(controlPointInfo, startTime, clockRate);
currentNoteObject.CurrentSliderVelocity = currentSliderVelocity;
currentNoteObject.EffectiveBPM = currentControlPoint.BPM * currentSliderVelocity;
}
}
/// <summary>
/// Calculates the slider velocity based on control point info and clock rate.
/// </summary>
private double calculateSliderVelocity(ControlPointInfo controlPointInfo, double startTime, double clockRate)
{
var activeEffectControlPoint = controlPointInfo.EffectPointAt(startTime);
return globalSliderVelocity * (activeEffectControlPoint.ScrollSpeed) * clockRate;
}
}
}

View File

@ -0,0 +1,94 @@
// 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.
using System.Collections.Generic;
using osu.Game.Rulesets.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
{
/// <summary>
/// Represents a group of <see cref="TaikoDifficultyHitObject"/>s with no rhythm variation.
/// </summary>
public class EvenHitObjects : EvenRhythm<TaikoDifficultyHitObject>, IHasInterval
{
public TaikoDifficultyHitObject FirstHitObject => Children[0];
public EvenHitObjects? Previous;
/// <summary>
/// <see cref="DifficultyHitObject.StartTime"/> of the first hit object.
/// </summary>
public double StartTime => Children[0].StartTime;
/// <summary>
/// The interval between the first and final hit object within this group.
/// </summary>
public double Duration => Children[^1].StartTime - Children[0].StartTime;
/// <summary>
/// The interval in ms of each hit object in this <see cref="EvenHitObjects"/>. This is only defined if there is
/// more than two hit objects in this <see cref="EvenHitObjects"/>.
/// </summary>
public double? HitObjectInterval;
/// <summary>
/// The ratio of <see cref="HitObjectInterval"/> between this and the previous <see cref="EvenHitObjects"/>. In the
/// case where one or both of the <see cref="HitObjectInterval"/> is undefined, this will have a value of 1.
/// </summary>
public double HitObjectIntervalRatio = 1;
/// <summary>
/// The interval between the <see cref="StartTime"/> of this and the previous <see cref="EvenHitObjects"/>.
/// </summary>
public double Interval { get; private set; } = double.PositiveInfinity;
public EvenHitObjects(EvenHitObjects? previous, List<TaikoDifficultyHitObject> data, ref int i)
: base(data, ref i, 5)
{
Previous = previous;
foreach (var hitObject in Children)
{
hitObject.Rhythm.EvenHitObjects = this;
// Pass the HitObjectInterval to each child.
hitObject.HitObjectInterval = HitObjectInterval;
}
calculateIntervals();
}
public static List<EvenHitObjects> GroupHitObjects(List<TaikoDifficultyHitObject> data)
{
List<EvenHitObjects> flatPatterns = new List<EvenHitObjects>();
// Index does not need to be incremented, as it is handled within EvenRhythm's constructor.
for (int i = 0; i < data.Count;)
{
EvenHitObjects? previous = flatPatterns.Count > 0 ? flatPatterns[^1] : null;
flatPatterns.Add(new EvenHitObjects(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;
}
}
}

View File

@ -0,0 +1,55 @@
// 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.
using System.Collections.Generic;
using System.Linq;
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
{
/// <summary>
/// Represents <see cref="EvenHitObjects"/> grouped by their <see cref="EvenHitObjects.StartTime"/>'s interval.
/// </summary>
public class EvenPatterns : EvenRhythm<EvenHitObjects>
{
public EvenPatterns? Previous { get; private set; }
/// <summary>
/// The <see cref="EvenHitObjects.Interval"/> between children <see cref="EvenHitObjects"/> within this group.
/// If there is only one child, this will have the value of the first child's <see cref="EvenHitObjects.Interval"/>.
/// </summary>
public double ChildrenInterval => Children.Count > 1 ? Children[1].Interval : Children[0].Interval;
/// <summary>
/// The ratio of <see cref="ChildrenInterval"/> between this and the previous <see cref="EvenPatterns"/>. In the
/// case where there is no previous <see cref="EvenPatterns"/>, this will have a value of 1.
/// </summary>
public double IntervalRatio => ChildrenInterval / Previous?.ChildrenInterval ?? 1.0d;
public TaikoDifficultyHitObject FirstHitObject => Children[0].FirstHitObject;
public IEnumerable<TaikoDifficultyHitObject> AllHitObjects => Children.SelectMany(child => child.Children);
private EvenPatterns(EvenPatterns? previous, List<EvenHitObjects> data, ref int i)
: base(data, ref i, 5)
{
Previous = previous;
foreach (TaikoDifficultyHitObject hitObject in AllHitObjects)
{
hitObject.Rhythm.EvenPatterns = this;
}
}
public static void GroupPatterns(List<EvenHitObjects> data)
{
List<EvenPatterns> evenPatterns = new List<EvenPatterns>();
// Index does not need to be incremented, as it is handled within the EvenRhythm constructor.
for (int i = 0; i < data.Count;)
{
EvenPatterns? previous = evenPatterns.Count > 0 ? evenPatterns[^1] : null;
evenPatterns.Add(new EvenPatterns(previous, data, ref i));
}
}
}
}

View File

@ -0,0 +1,73 @@
// 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.
using System;
using System.Collections.Generic;
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
{
/// <summary>
/// A base class for grouping <see cref="IHasInterval"/>s by their interval. In edges where an interval change
/// occurs, the <see cref="IHasInterval"/> is added to the group with the smaller interval.
/// </summary>
public abstract class EvenRhythm<ChildType>
where ChildType : IHasInterval
{
public IReadOnlyList<ChildType> Children { get; private set; }
/// <summary>
/// Determines if the intervals between two child objects are within a specified margin of error,
/// indicating that the intervals are effectively "flat" or consistent.
/// </summary>
private bool isFlat(ChildType current, ChildType previous, double marginOfError)
{
return Math.Abs(current.Interval - previous.Interval) <= marginOfError;
}
/// <summary>
/// Create a new <see cref="EvenRhythm{ChildType}"/> from a list of <see cref="IHasInterval"/>s, and add
/// them to the <see cref="Children"/> list until the end of the group.
/// </summary>
/// <param name="data">The list of <see cref="IHasInterval"/>s.</param>
/// <param name="i">
/// Index in <paramref name="data"/> to start adding children. This will be modified and should be passed into
/// the next <see cref="EvenRhythm{ChildType}"/>'s constructor.
/// </param>
/// <param name="marginOfError">
/// The margin of error for the interval, within of which no interval change is considered to have occured.
/// </param>
protected EvenRhythm(List<ChildType> data, ref int i, double marginOfError)
{
List<ChildType> children = new List<ChildType>();
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++;
}
}
}
}

View File

@ -0,0 +1,13 @@
// 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.
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm
{
/// <summary>
/// The interface for hitobjects that provide an interval value,
/// </summary>
public interface IHasInterval
{
double Interval { get; }
}
}

View File

@ -1,24 +1,82 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // 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 namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm
{ {
/// <summary> /// <summary>
/// Represents a rhythm change in a taiko map. /// Stores rhythm data for a <see cref="TaikoDifficultyHitObject"/>.
/// </summary> /// </summary>
public class TaikoDifficultyHitObjectRhythm public class TaikoDifficultyHitObjectRhythm
{ {
/// <summary>
/// The group of hit objects with consistent rhythm that this object belongs to.
/// </summary>
public EvenHitObjects? EvenHitObjects;
/// <summary>
/// The larger pattern of rhythm groups that this object is part of.
/// </summary>
public EvenPatterns? EvenPatterns;
/// <summary>
/// The ratio of current <see cref="Rulesets.Difficulty.Preprocessing.DifficultyHitObject.DeltaTime"/>
/// to previous <see cref="Rulesets.Difficulty.Preprocessing.DifficultyHitObject.DeltaTime"/> for the rhythm change.
/// A <see cref="Ratio"/> above 1 indicates a slow-down; a <see cref="Ratio"/> below 1 indicates a speed-up.
/// </summary>
public readonly double Ratio;
/// <summary> /// <summary>
/// The difficulty multiplier associated with this rhythm change. /// The difficulty multiplier associated with this rhythm change.
/// </summary> /// </summary>
public readonly double Difficulty; public readonly double Difficulty;
/// <summary> /// <summary>
/// The ratio of current <see cref="osu.Game.Rulesets.Difficulty.Preprocessing.DifficultyHitObject.DeltaTime"/> /// List of most common rhythm changes in taiko maps. This is used as a display value.
/// to previous <see cref="osu.Game.Rulesets.Difficulty.Preprocessing.DifficultyHitObject.DeltaTime"/> for the rhythm change.
/// A <see cref="Ratio"/> above 1 indicates a slow-down; a <see cref="Ratio"/> below 1 indicates a speed-up.
/// </summary> /// </summary>
public readonly double Ratio; /// <remarks>
/// The general guidelines for the values are:
/// <list type="bullet">
/// <item>rhythm changes with ratio closer to 1 (that are <i>not</i> 1) are harder to play,</item>
/// <item>speeding up is <i>generally</i> harder than slowing down (with exceptions of rhythm changes requiring a hand switch).</item>
/// </list>
/// </remarks>
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),
new TaikoDifficultyHitObjectRhythm(2, 3, 0.4),
new TaikoDifficultyHitObjectRhythm(5, 4, 0.5),
new TaikoDifficultyHitObjectRhythm(4, 5, 0.7)
};
/// <summary>
/// Initialises a new instance of <see cref="TaikoDifficultyHitObjectRhythm"/>s,
/// calculating the closest rhythm change and its associated difficulty for the current hit object.
/// </summary>
/// <param name="current">The current <see cref="TaikoDifficultyHitObject"/> being processed.</param>
public TaikoDifficultyHitObjectRhythm(TaikoDifficultyHitObject current)
{
var previous = current.Previous(0);
if (previous == null)
{
Ratio = 1;
Difficulty = 0.0;
return;
}
TaikoDifficultyHitObjectRhythm closestRhythm = getClosestRhythm(current.DeltaTime, previous.DeltaTime);
Ratio = closestRhythm.Ratio;
Difficulty = closestRhythm.Difficulty;
}
/// <summary> /// <summary>
/// Creates an object representing a rhythm change. /// Creates an object representing a rhythm change.
@ -26,10 +84,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm
/// <param name="numerator">The numerator for <see cref="Ratio"/>.</param> /// <param name="numerator">The numerator for <see cref="Ratio"/>.</param>
/// <param name="denominator">The denominator for <see cref="Ratio"/></param> /// <param name="denominator">The denominator for <see cref="Ratio"/></param>
/// <param name="difficulty">The difficulty multiplier associated with this rhythm change.</param> /// <param name="difficulty">The difficulty multiplier associated with this rhythm change.</param>
public TaikoDifficultyHitObjectRhythm(int numerator, int denominator, double difficulty) private TaikoDifficultyHitObjectRhythm(int numerator, int denominator, double difficulty)
{ {
Ratio = numerator / (double)denominator; Ratio = numerator / (double)denominator;
Difficulty = difficulty; Difficulty = difficulty;
} }
/// <summary>
/// Determines the closest rhythm change from <see cref="common_rhythms"/> that matches the timing ratio
/// between the current and previous intervals.
/// </summary>
/// <param name="currentDeltaTime">The time difference between the current hit object and the previous one.</param>
/// <param name="previousDeltaTime">The time difference between the previous hit object and the one before it.</param>
/// <returns>The closest matching rhythm from <see cref="common_rhythms"/>.</returns>
private TaikoDifficultyHitObjectRhythm getClosestRhythm(double currentDeltaTime, double previousDeltaTime)
{
double ratio = currentDeltaTime / previousDeltaTime;
return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First();
}
} }
} }

View File

@ -1,27 +1,36 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
{ {
/// <summary> /// <summary>
/// Represents a single hit object in taiko difficulty calculation. /// Represents a single hit object in taiko difficulty calculation.
/// </summary> /// </summary>
public class TaikoDifficultyHitObject : DifficultyHitObject public class TaikoDifficultyHitObject : DifficultyHitObject, IHasInterval
{ {
/// <summary> /// <summary>
/// The list of all <see cref="TaikoDifficultyHitObject"/> of the same colour as this <see cref="TaikoDifficultyHitObject"/> in the beatmap. /// The list of all <see cref="TaikoDifficultyHitObject"/> of the same colour as this <see cref="TaikoDifficultyHitObject"/> in the beatmap.
/// </summary> /// </summary>
private readonly IReadOnlyList<TaikoDifficultyHitObject>? monoDifficultyHitObjects; private readonly IReadOnlyList<TaikoDifficultyHitObject>? monoDifficultyHitObjects;
/// <summary>
/// The adjusted BPM of this hit object, based on its slider velocity and scroll speed.
/// </summary>
public double EffectiveBPM;
/// <summary>
/// The current slider velocity of this hit object.
/// </summary>
public double CurrentSliderVelocity;
/// <summary> /// <summary>
/// The index of this <see cref="TaikoDifficultyHitObject"/> in <see cref="monoDifficultyHitObjects"/>. /// The index of this <see cref="TaikoDifficultyHitObject"/> in <see cref="monoDifficultyHitObjects"/>.
/// </summary> /// </summary>
@ -42,12 +51,19 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
/// </summary> /// </summary>
public readonly TaikoDifficultyHitObjectRhythm Rhythm; public readonly TaikoDifficultyHitObjectRhythm Rhythm;
/// <summary>
/// The interval between this hit object and the surrounding hit objects in its rhythm group.
/// </summary>
public double? HitObjectInterval { get; set; }
/// <summary> /// <summary>
/// Colour data for this hit object. This is used by colour evaluator to calculate colour difficulty, but can be used /// 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. /// by other skills in the future.
/// </summary> /// </summary>
public readonly TaikoDifficultyHitObjectColour Colour; public readonly TaikoDifficultyHitObjectColour Colour;
public double Interval => DeltaTime;
/// <summary> /// <summary>
/// Creates a new difficulty hit object. /// Creates a new difficulty hit object.
/// </summary> /// </summary>
@ -71,7 +87,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
// Create the Colour object, its properties should be filled in by TaikoDifficultyPreprocessor // Create the Colour object, its properties should be filled in by TaikoDifficultyPreprocessor
Colour = new TaikoDifficultyHitObjectColour(); Colour = new TaikoDifficultyHitObjectColour();
Rhythm = getClosestRhythm(lastObject, lastLastObject, clockRate); Rhythm = new TaikoDifficultyHitObjectRhythm(this);
switch ((hitObject as Hit)?.Type) switch ((hitObject as Hit)?.Type)
{ {
@ -95,43 +111,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
} }
} }
/// <summary>
/// List of most common rhythm changes in taiko maps.
/// </summary>
/// <remarks>
/// The general guidelines for the values are:
/// <list type="bullet">
/// <item>rhythm changes with ratio closer to 1 (that are <i>not</i> 1) are harder to play,</item>
/// <item>speeding up is <i>generally</i> harder than slowing down (with exceptions of rhythm changes requiring a hand switch).</item>
/// </list>
/// </remarks>
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)
};
/// <summary>
/// Returns the closest rhythm change from <see cref="common_rhythms"/> required to hit this object.
/// </summary>
/// <param name="lastObject">The gameplay <see cref="HitObject"/> preceding this one.</param>
/// <param name="lastLastObject">The gameplay <see cref="HitObject"/> preceding <paramref name="lastObject"/>.</param>
/// <param name="clockRate">The rate of the gameplay clock.</param>
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? PreviousMono(int backwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex - (backwardsIndex + 1));
public TaikoDifficultyHitObject? NextMono(int forwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex + (forwardsIndex + 1)); public TaikoDifficultyHitObject? NextMono(int forwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex + (forwardsIndex + 1));

View File

@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
protected override double SkillMultiplier => 0.12; protected override double SkillMultiplier => 0.12;
// This is set to decay slower than other skills, due to the fact that only the first note of each encoding class // This is set to decay slower than other skills, due to the fact that only the first note of each encoding class
// having any difficulty values, and we want to allow colour difficulty to be able to build up even on // having any difficulty values, and we want to allow colour difficulty to be able to build up even on
// slower maps. // slower maps.
protected override double StrainDecayBase => 0.8; protected override double StrainDecayBase => 0.8;

View File

@ -0,0 +1,49 @@
// 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.
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
{
/// <summary>
/// Calculates the reading coefficient of taiko difficulty.
/// </summary>
public class Reading : StrainDecaySkill
{
protected override double SkillMultiplier => 1.0;
protected override double StrainDecayBase => 0.4;
private double currentStrain;
public double ObjectDensity { get; private set; }
public Reading(Mod[] mods)
: base(mods)
{
}
protected override double StrainValueOf(DifficultyHitObject current)
{
if (current is not TaikoDifficultyHitObject taikoObject)
return 0.0;
// Drum Rolls and Swells are exempt.
if (current.BaseObject is not Hit)
{
return 0.0;
}
ObjectDensity = ReadingEvaluator.CalculateObjectDensity(taikoObject) * 10;
currentStrain *= StrainDecayBase;
currentStrain += ReadingEvaluator.EvaluateDifficultyOf(taikoObject) * SkillMultiplier;
return currentStrain;
}
}
}

View File

@ -1,13 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // 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.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
{ {
@ -16,158 +13,26 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
/// </summary> /// </summary>
public class Rhythm : StrainDecaySkill public class Rhythm : StrainDecaySkill
{ {
protected override double SkillMultiplier => 10; protected override double SkillMultiplier => 1.6;
protected override double StrainDecayBase => 0; protected override double StrainDecayBase => 0.4;
/// <summary> private readonly double greatHitWindow;
/// The note-based decay for rhythm strain.
/// </summary>
/// <remarks>
/// <see cref="StrainDecayBase"/> is not used here, as it's time- and not note-based.
/// </remarks>
private const double strain_decay = 0.96;
/// <summary> public Rhythm(Mod[] mods, double greatHitWindow)
/// Maximum number of entries in <see cref="rhythmHistory"/>.
/// </summary>
private const int rhythm_history_max_length = 8;
/// <summary>
/// Contains the last <see cref="rhythm_history_max_length"/> changes in note sequence rhythms.
/// </summary>
private readonly LimitedCapacityQueue<TaikoDifficultyHitObject> rhythmHistory = new LimitedCapacityQueue<TaikoDifficultyHitObject>(rhythm_history_max_length);
/// <summary>
/// Contains the rolling rhythm strain.
/// Used to apply per-note decay.
/// </summary>
private double currentStrain;
/// <summary>
/// Number of notes since the last rhythm change has taken place.
/// </summary>
private int notesSinceRhythmChange;
public Rhythm(Mod[] mods)
: base(mods) : base(mods)
{ {
this.greatHitWindow = greatHitWindow;
} }
protected override double StrainValueOf(DifficultyHitObject current) protected override double StrainValueOf(DifficultyHitObject current)
{ {
// drum rolls and swells are exempt. double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current, greatHitWindow);
if (!(current.BaseObject is Hit))
{
resetRhythmAndStrain();
return 0.0;
}
currentStrain *= strain_decay; // To prevent abuse of exceedingly long intervals between awkward rhythms, we penalise its difficulty.
if (current.DeltaTime > 400)
difficulty *= 0.5;
TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current; return difficulty;
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;
}
/// <summary>
/// Returns a penalty to apply to the current hit object caused by repeating rhythm changes.
/// </summary>
/// <remarks>
/// Repetitions of more recent patterns are associated with a higher penalty.
/// </remarks>
/// <param name="hitObject">The current hit object being considered.</param>
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;
}
/// <summary>
/// Determines whether the rhythm change pattern starting at <paramref name="start"/> is a repeat of any of the
/// <paramref name="mostRecentPatternsToCompare"/>.
/// </summary>
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;
}
/// <summary>
/// Calculates a single rhythm repetition penalty.
/// </summary>
/// <param name="notesSince">Number of notes since the last repetition of a rhythm change.</param>
private static double repetitionPenalty(int notesSince) => Math.Min(1.0, 0.032 * notesSince);
/// <summary>
/// Calculates a penalty based on the number of notes since the last rhythm change.
/// Both rare and frequent rhythm changes are penalised.
/// </summary>
/// <param name="patternLength">Number of notes since the last rhythm change.</param>
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);
}
/// <summary>
/// Calculates a penalty for objects that do not require alternating hands.
/// </summary>
/// <param name="deltaTime">Time (in milliseconds) since the last hit object.</param>
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;
}
/// <summary>
/// Resets the rolling strain value and <see cref="notesSinceRhythmChange"/> counter.
/// </summary>
private void resetRhythmAndStrain()
{
currentStrain = 0.0;
notesSinceRhythmChange = 0;
} }
} }
} }

View File

@ -28,6 +28,18 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("rhythm_difficulty")] [JsonProperty("rhythm_difficulty")]
public double RhythmDifficulty { get; set; } public double RhythmDifficulty { get; set; }
/// <summary>
/// The difficulty corresponding to the reading skill.
/// </summary>
[JsonProperty("reading_difficulty")]
public double ReadingDifficulty { get; set; }
/// <summary>
/// Determines the object density of a beatmap, based on DeltaTime.
/// </summary>
[JsonProperty("object_density")]
public double ObjectDensity { get; set; }
/// <summary> /// <summary>
/// The difficulty corresponding to the colour skill. /// The difficulty corresponding to the colour skill.
/// </summary> /// </summary>
@ -35,10 +47,25 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public double ColourDifficulty { get; set; } public double ColourDifficulty { get; set; }
/// <summary> /// <summary>
/// The difficulty corresponding to the hardest parts of the map. /// The penalty factor corresponding to patterns based on their correlation between skills.
/// </summary> /// </summary>
[JsonProperty("peak_difficulty")] [JsonProperty("simple_pattern_factor")]
public double PeakDifficulty { get; set; } public double SimplePattern { get; set; }
/// <summary>
/// Determines the amount of difficult strain peaks present within each skill.
/// </summary>
[JsonProperty("stamina_difficult_strains")]
public double StaminaTopStrains { get; set; }
[JsonProperty("rhythm_difficult_strains")]
public double RhythmTopStrains { get; set; }
[JsonProperty("reading_difficult_strains")]
public double ReadingTopStrains { get; set; }
[JsonProperty("colour_difficult_strains")]
public double ColourTopStrains { get; set; }
/// <summary> /// <summary>
/// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc). /// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc).

View File

@ -8,10 +8,13 @@ using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; 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.Difficulty.Skills;
using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Rulesets.Taiko.Scoring;
@ -21,11 +24,20 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public class TaikoDifficultyCalculator : DifficultyCalculator public class TaikoDifficultyCalculator : DifficultyCalculator
{ {
private const double difficulty_multiplier = 0.084375; private const double difficulty_multiplier = 0.084375;
private const double rhythm_skill_multiplier = 0.2 * difficulty_multiplier; private const double rhythm_skill_multiplier = 0.070 * difficulty_multiplier;
private const double colour_skill_multiplier = 0.375 * difficulty_multiplier; private const double reading_skill_multiplier = 0.100 * difficulty_multiplier;
private const double colour_skill_multiplier = 0.425 * difficulty_multiplier;
private const double stamina_skill_multiplier = 0.375 * difficulty_multiplier; private const double stamina_skill_multiplier = 0.375 * difficulty_multiplier;
public override int Version => 20241007; private double simpleRhythmPenalty = 1;
private double simpleColourPenalty = 1;
private double colourDifficultStrains;
private double rhythmDifficultStrains;
private double readingDifficultStrains;
private double staminaDifficultStrains;
public override int Version => 20241115;
public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)
@ -34,9 +46,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
{ {
HitWindows hitWindows = new HitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
return new Skill[] return new Skill[]
{ {
new Rhythm(mods), new Rhythm(mods, hitWindows.WindowFor(HitResult.Great) / clockRate),
new Reading(mods),
new Colour(mods), new Colour(mods),
new Stamina(mods, false), new Stamina(mods, false),
new Stamina(mods, true) new Stamina(mods, true)
@ -53,25 +69,67 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{ {
List<DifficultyHitObject> difficultyHitObjects = new List<DifficultyHitObject>(); var hitWindows = new HitWindows();
List<TaikoDifficultyHitObject> centreObjects = new List<TaikoDifficultyHitObject>(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
List<TaikoDifficultyHitObject> rimObjects = new List<TaikoDifficultyHitObject>();
List<TaikoDifficultyHitObject> noteObjects = new List<TaikoDifficultyHitObject>();
var difficultyHitObjects = new List<DifficultyHitObject>();
var centreObjects = new List<TaikoDifficultyHitObject>();
var rimObjects = new List<TaikoDifficultyHitObject>();
var noteObjects = new List<TaikoDifficultyHitObject>();
EffectiveBPMPreprocessor bpmLoader = new EffectiveBPMPreprocessor(beatmap, noteObjects);
// Generate TaikoDifficultyHitObjects from the beatmap's hit objects.
for (int i = 2; i < beatmap.HitObjects.Count; i++) for (int i = 2; i < beatmap.HitObjects.Count; i++)
{ {
difficultyHitObjects.Add( difficultyHitObjects.Add(new TaikoDifficultyHitObject(
new TaikoDifficultyHitObject( beatmap.HitObjects[i],
beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, difficultyHitObjects, beatmap.HitObjects[i - 1],
centreObjects, rimObjects, noteObjects, difficultyHitObjects.Count) beatmap.HitObjects[i - 2],
); clockRate,
difficultyHitObjects,
centreObjects,
rimObjects,
noteObjects,
difficultyHitObjects.Count
));
} }
var groupedHitObjects = EvenHitObjects.GroupHitObjects(noteObjects);
TaikoColourDifficultyPreprocessor.ProcessAndAssign(difficultyHitObjects); TaikoColourDifficultyPreprocessor.ProcessAndAssign(difficultyHitObjects);
EvenPatterns.GroupPatterns(groupedHitObjects);
bpmLoader.ProcessEffectiveBPM(beatmap.ControlPointInfo, clockRate);
return difficultyHitObjects; return difficultyHitObjects;
} }
/// <summary>
/// Calculates the combined penalty based on the relationship between skills.
/// Lower skill values are penalised more heavily relative to predefined thresholds and their
/// interaction with the opposing skill rating.
/// </summary>
private double simplePatternPenalty(double rhythmRating, double colourRating, double clockRate)
{
double colourThreshold = 1.25 * clockRate; // Threshold changes based on rate
const double stamina_threshold = 1250.0;
// We count difficult stamina strains to ensure that even if there's no rhythm, very heavy stamina maps still give their respective difficulty.
double staminaTransition = Math.Clamp((staminaDifficultStrains - stamina_threshold) / (1350 - stamina_threshold), 0, 1);
double staminaFactor = (1 - staminaTransition) * 1.0 + staminaTransition * 0.85;
staminaFactor *= Math.Min(1, stamina_threshold / Math.Min(2000, staminaDifficultStrains));
simpleRhythmPenalty = patternRating(rhythmRating, 2.5, 5, colourRating);
simpleRhythmPenalty *= staminaFactor;
simpleRhythmPenalty = Math.Max(0, simpleRhythmPenalty);
// For awkwardly snapped maps with low rhythm strain count, we add a penalty.
double rhythmTransition = 1 - Math.Max(0, (50 - rhythmDifficultStrains) / 50.0);
double colourFactor = Math.Max(1, 0.50 * ((colourThreshold - colourRating) / colourThreshold));
simpleColourPenalty = Math.Max(0, rhythmTransition * colourFactor);
return simpleRhythmPenalty;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{ {
if (beatmap.HitObjects.Count == 0) if (beatmap.HitObjects.Count == 0)
@ -79,25 +137,32 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
Colour colour = (Colour)skills.First(x => x is Colour); Colour colour = (Colour)skills.First(x => x is Colour);
Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm); Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm);
Reading reading = (Reading)skills.First(x => x is Reading);
Stamina stamina = (Stamina)skills.First(x => x is Stamina); Stamina stamina = (Stamina)skills.First(x => x is Stamina);
Stamina singleColourStamina = (Stamina)skills.Last(x => x is Stamina); Stamina singleColourStamina = (Stamina)skills.Last(x => x is Stamina);
double colourRating = colour.DifficultyValue() * colour_skill_multiplier; double colourRating = colour.DifficultyValue() * colour_skill_multiplier;
double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier; double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier;
double readingRating = reading.DifficultyValue() * reading_skill_multiplier;
double objectDensity = reading.ObjectDensity;
double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier; double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5); double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5);
double combinedRating = combinedDifficultyValue(rhythm, colour, stamina); colourDifficultStrains = colour.CountTopWeightedStrains();
double starRating = rescale(combinedRating * 1.4); rhythmDifficultStrains = rhythm.CountTopWeightedStrains();
readingDifficultStrains = reading.CountTopWeightedStrains();
staminaDifficultStrains = stamina.CountTopWeightedStrains() * Math.Min(clockRate, 1.25); // Bonus is capped past 1.25x rate
// TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system. double patternPenalty = simplePatternPenalty(rhythmRating, colourRating, clockRate);
double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina);
double starRating = rescale(combinedRating * 1.8);
// Converts are penalised outside of the scope of difficulty calculation, as our assumptions surrounding playstyle becomes out-of-scope.
if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0) if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0)
{ {
starRating *= 0.925; starRating *= 0.80;
// For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused.
if (colourRating < 2 && staminaRating > 8)
starRating *= 0.80;
} }
HitWindows hitWindows = new TaikoHitWindows(); HitWindows hitWindows = new TaikoHitWindows();
@ -109,9 +174,15 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
Mods = mods, Mods = mods,
StaminaDifficulty = staminaRating, StaminaDifficulty = staminaRating,
MonoStaminaFactor = monoStaminaFactor, MonoStaminaFactor = monoStaminaFactor,
RhythmDifficulty = rhythmRating, SimplePattern = patternPenalty,
RhythmDifficulty = rhythmRating * 8,
ReadingDifficulty = readingRating * 1.5,
ObjectDensity = objectDensity,
ColourDifficulty = colourRating, ColourDifficulty = colourRating,
PeakDifficulty = combinedRating, StaminaTopStrains = staminaDifficultStrains,
RhythmTopStrains = rhythmDifficultStrains,
ReadingTopStrains = readingDifficultStrains,
ColourTopStrains = colourDifficultStrains,
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate, OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate,
MaxCombo = beatmap.GetMaxCombo(), MaxCombo = beatmap.GetMaxCombo(),
@ -120,17 +191,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
return attributes; return attributes;
} }
/// <summary>
/// Applies a final re-scaling of the star rating.
/// </summary>
/// <param name="sr">The raw star rating value before re-scaling.</param>
private double rescale(double sr)
{
if (sr < 0) return sr;
return 10.43 * Math.Log(sr / 8 + 1);
}
/// <summary> /// <summary>
/// Returns the combined star rating of the beatmap, calculated using peak strains from all sections of the map. /// Returns the combined star rating of the beatmap, calculated using peak strains from all sections of the map.
/// </summary> /// </summary>
@ -138,22 +198,26 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
/// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section.
/// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more).
/// </remarks> /// </remarks>
private double combinedDifficultyValue(Rhythm rhythm, Colour colour, Stamina stamina) private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina)
{ {
List<double> peaks = new List<double>(); List<double> peaks = new List<double>();
var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); var colourPeaks = colour.GetCurrentStrainPeaks().ToList();
var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList();
var readingPeaks = reading.GetCurrentStrainPeaks().ToList();
var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList(); var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList();
for (int i = 0; i < colourPeaks.Count; i++) for (int i = 0; i < colourPeaks.Count; i++)
{ {
double colourPeak = colourPeaks[i] * colour_skill_multiplier; // Peaks uses separate constants due to strain pertaining differently to display values.
double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier; double baseColourPeak = colourPeaks[i] * 0.0359;
double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier; double colourPeak = baseColourPeak * Math.Exp(-simpleRhythmPenalty / 14);
double rhythmPeak = rhythmPeaks[i] * 0.0379 * simpleColourPenalty;
double staminaPeak = staminaPeaks[i] * 0.0317;
double readingPeak = readingPeaks[i] * reading_skill_multiplier;
double peak = norm(1.5, colourPeak, staminaPeak); double peak = DifficultyCalculationUtils.Norm(1.5, colourPeak, staminaPeak);
peak = norm(2, peak, rhythmPeak); peak = DifficultyCalculationUtils.Norm(2, peak, rhythmPeak, readingPeak);
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// These sections will not contribute to the difficulty. // These sections will not contribute to the difficulty.
@ -174,10 +238,31 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
} }
/// <summary> /// <summary>
/// Returns the <i>p</i>-norm of an <i>n</i>-dimensional vector. /// Calculates the penalty for skills based on their relationship making up a pattern.
/// Penalising ratings where patterns have a major difference in value.
/// </summary> /// </summary>
/// <param name="p">The value of <i>p</i> to calculate the norm for.</param> private double patternRating(double rating, double threshold, double upperBound, double otherRating)
/// <param name="values">The coefficients of the vector.</param> {
private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); if (rating > threshold)
return 0;
// To prevent against breaking values we define 0.01 as minimum difficulty.
rating = Math.Max(0.01, rating);
otherRating = Math.Max(0.01, otherRating);
// Penalise based on logarithmic difference from the skill-based threshold, scaled by the influence of the other rating
return Math.Log(threshold / rating) * Math.Min(upperBound, Math.Log(Math.Max(1, otherRating - upperBound)) + upperBound);
}
/// <summary>
/// Applies a final re-scaling of the star rating.
/// </summary>
/// <param name="sr">The raw star rating value before re-scaling.</param>
private double rescale(double sr)
{
if (sr < 0) return sr;
return 10.43 * Math.Log(sr / 8 + 1);
}
} }
} }

View File

@ -86,9 +86,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
if (score.Mods.Any(m => m is ModHidden)) if (score.Mods.Any(m => m is ModHidden))
difficultyValue *= 1.025; difficultyValue *= 1.025;
if (score.Mods.Any(m => m is ModHardRock))
difficultyValue *= 1.10;
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>)) if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>))
difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus); difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus);
@ -97,7 +94,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
// Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps.
double accScalingExponent = 2 + attributes.MonoStaminaFactor; double accScalingExponent = 2 + attributes.MonoStaminaFactor;
double accScalingShift = 300 - 100 * attributes.MonoStaminaFactor; double accScalingShift = 400 - 100 * attributes.MonoStaminaFactor;
return difficultyValue * Math.Pow(SpecialFunctions.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent); return difficultyValue * Math.Pow(SpecialFunctions.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent);
} }
@ -159,7 +156,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
double n = totalHits; double n = totalHits;
// Proportion of greats + goods hit. // Proportion of greats + goods hit.
double p = totalSuccessfulHits / n; double p = Math.Max(0, totalSuccessfulHits - 0.0005 * countOk) / n;
// We can be 99% confident that p is at least this value. // We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Linq;
namespace osu.Game.Rulesets.Difficulty.Utils namespace osu.Game.Rulesets.Difficulty.Utils
{ {
@ -46,5 +47,23 @@ namespace osu.Game.Rulesets.Difficulty.Utils
/// <param name="exponent">Exponent</param> /// <param name="exponent">Exponent</param>
/// <returns>The output of logistic function</returns> /// <returns>The output of logistic function</returns>
public static double Logistic(double exponent, double maxValue = 1) => maxValue / (1 + Math.Exp(exponent)); public static double Logistic(double exponent, double maxValue = 1) => maxValue / (1 + Math.Exp(exponent));
/// <summary>
/// Calculates a Gaussian-based bell curve function (https://en.wikipedia.org/wiki/Gaussian_function)
/// </summary>
/// <param name="x">Value to calculate the function for</param>
/// <param name="mean">The mean (center) of the bell curve</param>
/// <param name="width">The width (spread) of the curve</param>
/// <param name="multiplier">Multiplier to adjust the curve's height</param>
/// <returns>The output of the bell curve function of <paramref name="x"/></returns>
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)));
/// <summary>
/// Returns the <i>p</i>-norm of an <i>n</i>-dimensional vector (https://en.wikipedia.org/wiki/Norm_(mathematics))
/// </summary>
/// <param name="p">The value of <i>p</i> to calculate the norm for.</param>
/// <param name="values">The coefficients of the vector.</param>
/// <returns>The <i>p</i>-norm of the vector.</returns>
public static double Norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p);
} }
} }