Skip to content

Instantly share code, notes, and snippets.

@peppy
Created February 23, 2018 04:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save peppy/beac8bf3d0da2191ef14d420ff1119e4 to your computer and use it in GitHub Desktop.
Save peppy/beac8bf3d0da2191ef14d420ff1119e4 to your computer and use it in GitHub Desktop.
osu!catch difficulty calculation (taken from osu-stable)
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;
}
}
}
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