mirror of https://github.com/ppy/osu
287 lines
12 KiB
C#
287 lines
12 KiB
C#
// 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 System.Linq;
|
|
using osu.Game.Rulesets.Difficulty;
|
|
using osu.Game.Rulesets.Mods;
|
|
using osu.Game.Rulesets.Osu.Mods;
|
|
using osu.Game.Rulesets.Scoring;
|
|
using osu.Game.Scoring;
|
|
using MathNet.Numerics;
|
|
|
|
namespace osu.Game.Rulesets.Osu.Difficulty
|
|
{
|
|
public class OsuPerformanceCalculator : PerformanceCalculator
|
|
{
|
|
public new OsuDifficultyAttributes Attributes => (OsuDifficultyAttributes)base.Attributes;
|
|
|
|
private Mod[] mods;
|
|
|
|
private double accuracy;
|
|
private int scoreMaxCombo;
|
|
private int countGreat;
|
|
private int countOk;
|
|
private int countMeh;
|
|
private int countMiss;
|
|
|
|
private int effectiveMissCount;
|
|
|
|
public OsuPerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score)
|
|
: base(ruleset, attributes, score)
|
|
{
|
|
}
|
|
|
|
public override double Calculate(Dictionary<string, double> categoryRatings = null)
|
|
{
|
|
mods = Score.Mods;
|
|
accuracy = Score.Accuracy;
|
|
scoreMaxCombo = Score.MaxCombo;
|
|
countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great);
|
|
countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok);
|
|
countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh);
|
|
countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss);
|
|
effectiveMissCount = calculateEffectiveMissCount();
|
|
|
|
double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
|
|
|
|
// Custom multipliers for NoFail and SpunOut.
|
|
if (mods.Any(m => m is OsuModNoFail))
|
|
multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount);
|
|
|
|
if (mods.Any(m => m is OsuModSpunOut))
|
|
multiplier *= 1.0 - Math.Pow((double)Attributes.SpinnerCount / totalHits, 0.85);
|
|
|
|
if (mods.Any(h => h is OsuModRelax))
|
|
{
|
|
// As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
|
|
effectiveMissCount = Math.Min(effectiveMissCount + countOk + countMeh, totalHits);
|
|
|
|
multiplier *= 0.6;
|
|
}
|
|
|
|
double aimValue = computeAimValue();
|
|
double speedValue = computeSpeedValue();
|
|
double accuracyValue = computeAccuracyValue();
|
|
double flashlightValue = computeFlashlightValue();
|
|
double totalValue =
|
|
Math.Pow(
|
|
Math.Pow(aimValue, 1.1) +
|
|
Math.Pow(speedValue, 1.1) +
|
|
Math.Pow(accuracyValue, 1.1) +
|
|
Math.Pow(flashlightValue, 1.1), 1.0 / 1.1
|
|
) * multiplier;
|
|
|
|
if (categoryRatings != null)
|
|
{
|
|
categoryRatings.Add("Aim", aimValue);
|
|
categoryRatings.Add("Speed", speedValue);
|
|
categoryRatings.Add("Accuracy", accuracyValue);
|
|
categoryRatings.Add("Flashlight", flashlightValue);
|
|
categoryRatings.Add("OD", Attributes.OverallDifficulty);
|
|
categoryRatings.Add("AR", Attributes.ApproachRate);
|
|
categoryRatings.Add("Max Combo", Attributes.MaxCombo);
|
|
}
|
|
|
|
return totalValue;
|
|
}
|
|
|
|
private double computeAimValue()
|
|
{
|
|
double rawAim = Attributes.AimStrain;
|
|
|
|
if (mods.Any(m => m is OsuModTouchDevice))
|
|
rawAim = Math.Pow(rawAim, 0.8);
|
|
|
|
double aimValue = Math.Pow(5.0 * Math.Max(1.0, rawAim / 0.0675) - 4.0, 3.0) / 100000.0;
|
|
|
|
// Longer maps are worth more.
|
|
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
|
|
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
|
|
|
|
aimValue *= lengthBonus;
|
|
|
|
if (effectiveMissCount > 0)
|
|
aimValue *= calculateMissPenalty(effectiveMissCount);
|
|
|
|
double approachRateFactor = 0.0;
|
|
if (Attributes.ApproachRate > 10.33)
|
|
approachRateFactor = 0.3 * (Attributes.ApproachRate - 10.33);
|
|
else if (Attributes.ApproachRate < 8.0)
|
|
approachRateFactor = 0.1 * (8.0 - Attributes.ApproachRate);
|
|
|
|
aimValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR.
|
|
|
|
if (mods.Any(m => m is OsuModBlinds))
|
|
aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * Attributes.DrainRate * Attributes.DrainRate);
|
|
else if (mods.Any(h => h is OsuModHidden))
|
|
{
|
|
// We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR.
|
|
aimValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate);
|
|
}
|
|
|
|
// We assume 15% of sliders in a map are difficult since there's no way to tell from the performance calculator.
|
|
double estimateDifficultSliders = Attributes.SliderCount * 0.15;
|
|
|
|
if (Attributes.SliderCount > 0)
|
|
{
|
|
double estimateSliderEndsDropped = Math.Clamp(Math.Min(countOk + countMeh + countMiss, Attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders);
|
|
double sliderNerfFactor = (1 - Attributes.SliderFactor) * Math.Pow(1 - estimateSliderEndsDropped / estimateDifficultSliders, 3) + Attributes.SliderFactor;
|
|
aimValue *= sliderNerfFactor;
|
|
}
|
|
|
|
aimValue *= accuracy;
|
|
// It is important to also consider accuracy difficulty when doing that.
|
|
aimValue *= 0.98 + Math.Pow(Attributes.OverallDifficulty, 2) / 2500;
|
|
|
|
return aimValue;
|
|
}
|
|
|
|
private double computeSpeedValue()
|
|
{
|
|
double speedValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.SpeedStrain / 0.0675) - 4.0, 3.0) / 100000.0;
|
|
|
|
// Longer maps are worth more.
|
|
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
|
|
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
|
|
speedValue *= lengthBonus;
|
|
|
|
if (effectiveMissCount > 0)
|
|
speedValue *= calculateMissPenalty(effectiveMissCount);
|
|
|
|
double approachRateFactor = 0.0;
|
|
if (Attributes.ApproachRate > 10.33)
|
|
approachRateFactor = 0.3 * (Attributes.ApproachRate - 10.33);
|
|
|
|
speedValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR.
|
|
|
|
if (mods.Any(m => m is OsuModBlinds))
|
|
{
|
|
// Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given.
|
|
speedValue *= 1.12;
|
|
}
|
|
else if (mods.Any(m => m is OsuModHidden))
|
|
{
|
|
// We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR.
|
|
speedValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate);
|
|
}
|
|
|
|
// Scale the speed value with accuracy and OD.
|
|
speedValue *= (0.95 + Math.Pow(Attributes.OverallDifficulty, 2) / 750) * Math.Pow(accuracy, (14.5 - Math.Max(Attributes.OverallDifficulty, 8)) / 2);
|
|
|
|
// Scale the speed value with # of 50s to punish doubletapping.
|
|
speedValue *= Math.Pow(0.98, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0);
|
|
|
|
return speedValue;
|
|
}
|
|
|
|
private double computeAccuracyValue()
|
|
{
|
|
if (mods.Any(h => h is OsuModRelax))
|
|
return 0.0;
|
|
|
|
// This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window.
|
|
double betterAccuracyPercentage;
|
|
int amountHitObjectsWithAccuracy = Attributes.HitCircleCount;
|
|
|
|
if (amountHitObjectsWithAccuracy > 0)
|
|
betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6);
|
|
else
|
|
betterAccuracyPercentage = 0;
|
|
|
|
// It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points.
|
|
if (betterAccuracyPercentage < 0)
|
|
betterAccuracyPercentage = 0;
|
|
|
|
// Lots of arbitrary values from testing.
|
|
// Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution.
|
|
double accuracyValue = Math.Pow(1.52163, Attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83;
|
|
|
|
// Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
|
|
accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3));
|
|
|
|
// Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given.
|
|
if (mods.Any(m => m is OsuModBlinds))
|
|
accuracyValue *= 1.14;
|
|
else if (mods.Any(m => m is OsuModHidden))
|
|
accuracyValue *= 1.08;
|
|
|
|
if (mods.Any(m => m is OsuModFlashlight))
|
|
accuracyValue *= 1.02;
|
|
|
|
return accuracyValue;
|
|
}
|
|
|
|
private double computeFlashlightValue()
|
|
{
|
|
if (!mods.Any(h => h is OsuModFlashlight))
|
|
return 0.0;
|
|
|
|
double rawFlashlight = Attributes.FlashlightRating;
|
|
|
|
if (mods.Any(m => m is OsuModTouchDevice))
|
|
rawFlashlight = Math.Pow(rawFlashlight, 0.8);
|
|
|
|
double flashlightValue = Math.Pow(rawFlashlight, 2.0) * 25.0;
|
|
|
|
// Add an additional bonus for HDFL.
|
|
if (mods.Any(h => h is OsuModHidden))
|
|
flashlightValue *= 1.3;
|
|
|
|
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
|
|
if (effectiveMissCount > 0)
|
|
flashlightValue *= 0.97 * Math.Pow(1 - Math.Pow((double)effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875));
|
|
|
|
// Combo scaling.
|
|
if (Attributes.MaxCombo > 0)
|
|
flashlightValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0);
|
|
|
|
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
|
|
flashlightValue *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) +
|
|
(totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0);
|
|
|
|
// Scale the flashlight value with accuracy _slightly_.
|
|
flashlightValue *= 0.5 + accuracy / 2.0;
|
|
// It is important to also consider accuracy difficulty when doing that.
|
|
flashlightValue *= 0.98 + Math.Pow(Attributes.OverallDifficulty, 2) / 2500;
|
|
|
|
return flashlightValue;
|
|
}
|
|
|
|
private int calculateEffectiveMissCount()
|
|
{
|
|
// Guess the number of misses + slider breaks from combo
|
|
double comboBasedMissCount = 0.0;
|
|
|
|
if (Attributes.SliderCount > 0)
|
|
{
|
|
double fullComboThreshold = Attributes.MaxCombo - 0.1 * Attributes.SliderCount;
|
|
if (scoreMaxCombo < fullComboThreshold)
|
|
comboBasedMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
|
|
}
|
|
|
|
// Clamp misscount since it's derived from combo and can be higher than total hits and that breaks some calculations
|
|
comboBasedMissCount = Math.Min(comboBasedMissCount, totalHits);
|
|
|
|
return Math.Max(countMiss, (int)Math.Floor(comboBasedMissCount));
|
|
}
|
|
|
|
private double calculateMissPenalty(double missCount)
|
|
{
|
|
double leniency = 4.3;
|
|
|
|
if (missCount > totalHits - leniency)
|
|
return 0;
|
|
|
|
double missApprox = SpecialFunctions.ErfInv((totalHits - leniency - missCount) / totalHits);
|
|
double fcApprox = SpecialFunctions.ErfInv((totalHits - leniency) / totalHits);
|
|
|
|
return Math.Pow(missApprox / fcApprox, 3.5);
|
|
}
|
|
|
|
private int totalHits => countGreat + countOk + countMeh + countMiss;
|
|
private int totalSuccessfulHits => countGreat + countOk + countMeh;
|
|
}
|
|
}
|