Created
February 23, 2018 04:42
-
-
Save peppy/beac8bf3d0da2191ef14d420ff1119e4 to your computer and use it in GitHub Desktop.
osu!catch difficulty calculation (taken from osu-stable)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Diagnostics; | |
using System.Text; | |
using osu.GameModes.Edit.AiMod; | |
using osu.GameplayElements.HitObjects; | |
using osu.GameplayElements.HitObjects.Fruits; | |
using osu_common; | |
namespace osu.GameplayElements.Beatmaps | |
{ | |
internal class BeatmapDifficultyCalculatorFruits : BeatmapDifficultyCalculator | |
{ | |
private const double STAR_SCALING_FACTOR = 0.145; | |
private const float PLAYFIELD_WIDTH = 512; | |
protected override PlayModes PlayMode => PlayModes.CatchTheBeat; | |
public static Mods RelevantMods => Mods.DoubleTime | Mods.HalfTime | Mods.HardRock | Mods.Easy; | |
/// <summary> | |
/// HitObjects are stored as a member variable. | |
/// </summary> | |
internal List<DifficultyHitObjectFruits> DifficultyHitObjects; | |
public BeatmapDifficultyCalculatorFruits(Beatmap beatmap) | |
: base(beatmap) | |
{ | |
} | |
internal override HitObjectManager NewHitObjectManager() | |
{ | |
return new HitObjectManagerFruits(false); | |
} | |
protected override bool ModsRequireReload(Mods mods) | |
{ | |
// If we switch to hardrock or away from hardrock, then we need to reload! | |
return ((HitObjectManager.ActiveMods ^ mods) & Mods.HardRock) > 0; | |
} | |
protected override double ComputeDifficulty(Dictionary<String, String> categoryDifficulty) | |
{ | |
// Fill our custom DifficultyHitObject class, that carries additional information | |
DifficultyHitObjects = new List<DifficultyHitObjectFruits>(HitObjects.Count); | |
float catcherWidth = 305 / GameBase.GameField.Ratio * HitObjectManager.SpriteRatio * 0.7f; | |
float catcherWidthHalf = catcherWidth / 2; | |
HitObjectManagerFruits hom = HitObjectManager as HitObjectManagerFruits; | |
hom.InitializeHyperDash(catcherWidth); | |
catcherWidthHalf *= 0.8f; | |
foreach (HitObject hitObject in HitObjects) | |
{ | |
// We want to only consider fruits that contribute to the combo. Droplets are addressed as accuracy and spinners are not relevant for "skill" calculations. | |
if (!(hitObject is HitCircleFruitsTickTiny) && !(hitObject is HitCircleFruitsSpin)) | |
{ | |
DifficultyHitObjects.Add(new DifficultyHitObjectFruits(hitObject, catcherWidthHalf)); | |
} | |
} | |
// Sort DifficultyHitObjects by StartTime of the HitObjects - just to make sure. Not using CompareTo, since it results in a crash (HitObjectBase inherits MarshalByRefObject) | |
DifficultyHitObjects.Sort((a, b) => a.BaseHitObject.StartTime - b.BaseHitObject.StartTime); | |
if (!CalculateStrainValues()) return 0; | |
double starRating = Math.Sqrt(CalculateDifficulty()) * STAR_SCALING_FACTOR; | |
if (categoryDifficulty != null) | |
{ | |
categoryDifficulty.Add("Aim", starRating.ToString("0.00", GameBase.nfi)); | |
double preEmpt = HitObjectManager.PreEmpt / TimeRate; | |
categoryDifficulty.Add("AR", (preEmpt > 1200.0 ? -(preEmpt - 1800.0) / 120.0 : -(preEmpt - 1200.0) / 150.0 + 5.0).ToString("0.00", GameBase.nfi)); | |
categoryDifficulty.Add("Max combo", DifficultyHitObjects.Count.ToString(GameBase.nfi)); | |
} | |
return starRating; | |
} | |
protected override bool CalculateStrainValues() | |
{ | |
// Traverse hitObjects in pairs to calculate the strain value of NextHitObject from the strain value of CurrentHitObject and environment. | |
List<DifficultyHitObjectFruits>.Enumerator hitObjectsEnumerator = DifficultyHitObjects.GetEnumerator(); | |
if (!hitObjectsEnumerator.MoveNext()) return false; | |
DifficultyHitObjectFruits currentHitObject = hitObjectsEnumerator.Current; | |
DifficultyHitObjectFruits nextHitObject; | |
// First hitObject starts at strain 1. 1 is the default for strain values, so we don't need to set it here. See DifficultyHitObject. | |
while (hitObjectsEnumerator.MoveNext()) | |
{ | |
nextHitObject = hitObjectsEnumerator.Current; | |
nextHitObject.CalculateStrains(currentHitObject, TimeRate); | |
currentHitObject = nextHitObject; | |
} | |
return true; | |
} | |
/// <summary> | |
/// In milliseconds. For difficulty calculation we will only look at the highest strain value in each time interval of size STRAIN_STEP. | |
/// This is to eliminate higher influence of stream over aim by simply having more HitObjects with high strain. | |
/// The higher this value, the less strains there will be, indirectly giving long beatmaps an advantage. | |
/// </summary> | |
protected const double STRAIN_STEP = 750; | |
/// <summary> | |
/// The weighting of each strain value decays to this number * it's previous value | |
/// </summary> | |
protected const double DECAY_WEIGHT = 0.94; | |
protected override double CalculateDifficulty() | |
{ | |
// The strain step needs to be adjusted for the algorithm to be considered equal with speed changing mods | |
double actualStrainStep = STRAIN_STEP * TimeRate; | |
// Find the highest strain value within each strain step | |
List<double> highestStrains = new List<double>(); | |
double intervalEndTime = actualStrainStep; | |
double maximumStrain = 0; // We need to keep track of the maximum strain in the current interval | |
DifficultyHitObjectFruits previousHitObject = null; | |
foreach (DifficultyHitObjectFruits hitObject in DifficultyHitObjects) | |
{ | |
// While we are beyond the current interval push the currently available maximum to our strain list | |
while (hitObject.BaseHitObject.StartTime > intervalEndTime) | |
{ | |
highestStrains.Add(maximumStrain); | |
// The maximum strain of the next interval is not zero by default! We need to take the last hitObject we encountered, take its strain and apply the decay | |
// until the beginning of the next interval. | |
if (previousHitObject == null) | |
{ | |
maximumStrain = 0; | |
} | |
else | |
{ | |
double decay = Math.Pow(DifficultyHitObjectFruits.DECAY_BASE, (intervalEndTime - previousHitObject.BaseHitObject.StartTime) / 1000); | |
maximumStrain = previousHitObject.Strain * decay; | |
} | |
// Go to the next time interval | |
intervalEndTime += actualStrainStep; | |
} | |
// Obtain maximum strain | |
maximumStrain = Math.Max(hitObject.Strain, maximumStrain); | |
previousHitObject = hitObject; | |
} | |
// Build the weighted sum over the highest strains for each interval | |
double difficulty = 0; | |
double weight = 1; | |
highestStrains.Sort((a, b) => b.CompareTo(a)); // Sort from highest to lowest strain. | |
foreach (double strain in highestStrains) | |
{ | |
difficulty += weight * strain; | |
weight *= DECAY_WEIGHT; | |
} | |
return difficulty; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Text; | |
using Microsoft.Xna.Framework; | |
using osu.GameplayElements.Beatmaps; | |
using osu.Helpers; | |
using System.Diagnostics; | |
namespace osu.GameplayElements.HitObjects.Fruits | |
{ | |
internal class DifficultyHitObjectFruits | |
{ | |
/// <summary> | |
/// Factor by how much individual / overall strain decays per second. | |
/// </summary> | |
/// <remarks> | |
/// Those values are results of tweaking a lot and taking into account general feedback. | |
/// </remarks> | |
internal static readonly double DECAY_BASE = 0.20; | |
private const float NORMALIZED_HITOBJECT_RADIUS = 41.0f; | |
private const float ABSOLUTE_PLAYER_POSITIONING_ERROR = 16f; | |
private float playerPositioningError; | |
internal HitObject BaseHitObject; | |
/// <summary> | |
/// Measures jump difficulty. CtB doesn't have something like button pressing speed or accuracy | |
/// </summary> | |
internal double Strain = 1; | |
/// <summary> | |
/// This is required to keep track of lazy player movement (always moving only as far as necessary) | |
/// Without this quick repeat sliders / weirdly shaped streams might become ridiculously overrated | |
/// </summary> | |
internal float PlayerPositionOffset; | |
internal float LastMovement; | |
internal float NormalizedPosition; | |
internal float ActualNormalizedPosition => NormalizedPosition + PlayerPositionOffset; | |
internal DifficultyHitObjectFruits(HitObject baseHitObject, float catcherWidthHalf) | |
{ | |
BaseHitObject = baseHitObject; | |
// We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. | |
float scalingFactor = NORMALIZED_HITOBJECT_RADIUS / catcherWidthHalf; | |
playerPositioningError = ABSOLUTE_PLAYER_POSITIONING_ERROR;// * scalingFactor; | |
NormalizedPosition = baseHitObject.Position.X * scalingFactor; | |
} | |
private const double DIRECTION_CHANGE_BONUS = 12.5; | |
internal void CalculateStrains(DifficultyHitObjectFruits previousHitObject, double timeRate) | |
{ | |
// Rather simple, but more specialized things are inherently inaccurate due to the big difference playstyles and opinions make. | |
// See Taiko feedback thread. | |
double timeElapsed = (BaseHitObject.StartTime - previousHitObject.BaseHitObject.StartTime) / timeRate; | |
double decay = Math.Pow(DECAY_BASE, timeElapsed / 1000); | |
// Update new position with lazy movement. | |
PlayerPositionOffset = | |
OsuMathHelper.Clamp( | |
previousHitObject.ActualNormalizedPosition, | |
NormalizedPosition - (NORMALIZED_HITOBJECT_RADIUS - playerPositioningError), | |
NormalizedPosition + (NORMALIZED_HITOBJECT_RADIUS - playerPositioningError)) // Obtain new lazy position, but be stricter by allowing for an error of a certain degree of the player. | |
- NormalizedPosition; // Subtract HitObject position to obtain offset | |
LastMovement = DistanceTo(previousHitObject); | |
double addition = spacingWeight(LastMovement); | |
if (NormalizedPosition < previousHitObject.NormalizedPosition) | |
{ | |
LastMovement = -LastMovement; | |
} | |
HitCircleFruits previousHitCircle = previousHitObject.BaseHitObject as HitCircleFruits; | |
double additionBonus = 0; | |
double sqrtTime = Math.Sqrt(Math.Max(timeElapsed, 25)); | |
// Direction changes give an extra point! | |
if (Math.Abs(LastMovement) > 0.1) | |
{ | |
if (Math.Abs(previousHitObject.LastMovement) > 0.1 && Math.Sign(LastMovement) != Math.Sign(previousHitObject.LastMovement)) | |
{ | |
double bonus = DIRECTION_CHANGE_BONUS / sqrtTime; | |
// Weight bonus by how | |
double bonusFactor = Math.Min(playerPositioningError, Math.Abs(LastMovement)) / playerPositioningError; | |
// We want time to play a role twice here! | |
addition += bonus * bonusFactor; | |
// Bonus for tougher direction switches and "almost" hyperdashes at this point | |
if (previousHitCircle != null && previousHitCircle.DistanceToHyperDash <= 10) | |
{ | |
additionBonus += 0.3 * bonusFactor; | |
} | |
} | |
// Base bonus for every movement, giving some weight to streams. | |
addition += 7.5 * Math.Min(Math.Abs(LastMovement), NORMALIZED_HITOBJECT_RADIUS * 2) / (NORMALIZED_HITOBJECT_RADIUS * 6) / sqrtTime; | |
} | |
// Bonus for "almost" hyperdashes at corner points | |
if (previousHitCircle != null && previousHitCircle.DistanceToHyperDash <= 10) | |
{ | |
if (!previousHitCircle.HyperDash) | |
{ | |
additionBonus += 1.0; | |
} | |
else | |
{ | |
// After a hyperdash we ARE in the correct position. Always! | |
PlayerPositionOffset = 0; | |
} | |
addition *= 1.0 + additionBonus * ((10 - previousHitCircle.DistanceToHyperDash) / 10); | |
} | |
addition *= 850.0 / Math.Max(timeElapsed, 25); | |
Strain = previousHitObject.Strain * decay + addition; | |
} | |
private static double spacingWeight(float distance) | |
{ | |
return Math.Pow(distance, 1.3) / 500; | |
} | |
internal float DistanceTo(DifficultyHitObjectFruits other) | |
{ | |
return Math.Abs(ActualNormalizedPosition - other.ActualNormalizedPosition); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment