2017-02-20 00:41:51 +08:00
// Copyright (c) 2007-2017 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using OpenTK ;
using System ;
using System.Diagnostics ;
using System.Linq ;
namespace osu.Game.Modes.Osu.Objects
{
2017-03-07 09:59:19 +08:00
internal class OsuHitObjectDifficulty
2017-02-20 00:41:51 +08:00
{
/// <summary>
/// Factor by how much speed / aim strain decays per second.
/// </summary>
/// <remarks>
/// These values are results of tweaking a lot and taking into account general feedback.
/// Opinionated observation: Speed is easier to maintain than accurate jumps.
/// </remarks>
internal static readonly double [ ] DECAY_BASE = { 0.3 , 0.15 } ;
/// <summary>
/// Pseudo threshold values to distinguish between "singles" and "streams"
/// </summary>
/// <remarks>
/// Of course the border can not be defined clearly, therefore the algorithm has a smooth transition between those values.
/// They also are based on tweaking and general feedback.
/// </remarks>
2017-02-20 01:11:14 +08:00
private const double stream_spacing_threshold = 110 ,
single_spacing_threshold = 125 ;
2017-02-20 00:41:51 +08:00
/// <summary>
/// Scaling values for weightings to keep aim and speed difficulty in balance.
/// </summary>
/// <remarks>
/// Found from testing a very large map pool (containing all ranked maps) and keeping the average values the same.
/// </remarks>
2017-02-20 01:11:14 +08:00
private static readonly double [ ] spacing_weight_scaling = { 1400 , 26.25 } ;
2017-02-20 00:41:51 +08:00
/// <summary>
/// Almost the normed diameter of a circle (104 osu pixel). That is -after- position transforming.
/// </summary>
2017-02-20 01:11:14 +08:00
private const double almost_diameter = 90 ;
2017-02-20 00:41:51 +08:00
internal OsuHitObject BaseHitObject ;
internal double [ ] Strains = { 1 , 1 } ;
internal int MaxCombo = 1 ;
2017-02-20 01:21:45 +08:00
private float scalingFactor ;
2017-02-20 00:41:51 +08:00
private float lazySliderLength ;
2017-02-20 01:21:45 +08:00
private Vector2 startPosition ;
private Vector2 endPosition ;
2017-02-20 00:41:51 +08:00
internal OsuHitObjectDifficulty ( OsuHitObject baseHitObject )
{
BaseHitObject = baseHitObject ;
float circleRadius = baseHitObject . Scale * 64 ;
Slider slider = BaseHitObject as Slider ;
if ( slider ! = null )
MaxCombo + = slider . Ticks . Count ( ) ;
// We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps.
2017-03-07 09:59:19 +08:00
scalingFactor = 52.0f / circleRadius ;
2017-02-20 00:41:51 +08:00
if ( circleRadius < 30 )
{
float smallCircleBonus = Math . Min ( 30.0f - circleRadius , 5.0f ) / 50.0f ;
scalingFactor * = 1.0f + smallCircleBonus ;
}
lazySliderLength = 0 ;
2017-02-20 01:21:45 +08:00
startPosition = baseHitObject . StackedPosition ;
2017-02-20 00:41:51 +08:00
// Calculate approximation of lazy movement on the slider
if ( slider ! = null )
{
float sliderFollowCircleRadius = circleRadius * 3 ; // Not sure if this is correct, but here we do not need 100% exact values. This comes pretty darn close in my tests.
// For simplifying this step we use actual osu! coordinates and simply scale the length, that we obtain by the ScalingFactor later
2017-02-20 01:21:45 +08:00
Vector2 cursorPos = startPosition ;
2017-02-20 00:41:51 +08:00
Action < Vector2 > addSliderVertex = delegate ( Vector2 pos )
{
Vector2 difference = pos - cursorPos ;
float distance = difference . Length ;
// Did we move away too far?
if ( distance > sliderFollowCircleRadius )
{
// Yep, we need to move the cursor
difference . Normalize ( ) ; // Obtain the direction of difference. We do no longer need the actual difference
distance - = sliderFollowCircleRadius ;
cursorPos + = difference * distance ; // We move the cursor just as far as needed to stay in the follow circle
lazySliderLength + = distance ;
}
} ;
// Actual computation of the first lazy curve
foreach ( var tick in slider . Ticks )
addSliderVertex ( tick . StackedPosition ) ;
addSliderVertex ( baseHitObject . StackedEndPosition ) ;
lazySliderLength * = scalingFactor ;
2017-02-20 01:21:45 +08:00
endPosition = cursorPos ;
2017-02-20 00:41:51 +08:00
}
// We have a normal HitCircle or a spinner
else
2017-02-20 01:21:45 +08:00
endPosition = startPosition ;
2017-02-20 00:41:51 +08:00
}
internal void CalculateStrains ( OsuHitObjectDifficulty previousHitObject , double timeRate )
{
calculateSpecificStrain ( previousHitObject , OsuDifficultyCalculator . DifficultyType . Speed , timeRate ) ;
calculateSpecificStrain ( previousHitObject , OsuDifficultyCalculator . DifficultyType . Aim , timeRate ) ;
}
// Caution: The subjective values are strong with this one
private static double spacingWeight ( double distance , OsuDifficultyCalculator . DifficultyType type )
{
switch ( type )
{
case OsuDifficultyCalculator . DifficultyType . Speed :
2017-02-20 01:11:14 +08:00
if ( distance > single_spacing_threshold )
2017-02-20 00:41:51 +08:00
return 2.5 ;
2017-02-20 01:11:14 +08:00
else if ( distance > stream_spacing_threshold )
return 1.6 + 0.9 * ( distance - stream_spacing_threshold ) / ( single_spacing_threshold - stream_spacing_threshold ) ;
else if ( distance > almost_diameter )
return 1.2 + 0.4 * ( distance - almost_diameter ) / ( stream_spacing_threshold - almost_diameter ) ;
else if ( distance > almost_diameter / 2 )
2017-03-07 09:59:19 +08:00
return 0.95 + 0.25 * ( distance - almost_diameter / 2 ) / ( almost_diameter / 2 ) ;
2017-02-20 00:41:51 +08:00
else
return 0.95 ;
case OsuDifficultyCalculator . DifficultyType . Aim :
return Math . Pow ( distance , 0.99 ) ;
}
Debug . Assert ( false , "Invalid osu difficulty hit object type." ) ;
return 0 ;
}
private void calculateSpecificStrain ( OsuHitObjectDifficulty previousHitObject , OsuDifficultyCalculator . DifficultyType type , double timeRate )
{
double addition = 0 ;
double timeElapsed = ( BaseHitObject . StartTime - previousHitObject . BaseHitObject . StartTime ) / timeRate ;
double decay = Math . Pow ( DECAY_BASE [ ( int ) type ] , timeElapsed / 1000 ) ;
2017-03-20 12:10:24 +08:00
if ( BaseHitObject is Spinner )
2017-02-20 00:41:51 +08:00
{
// Do nothing for spinners
}
2017-03-20 12:10:24 +08:00
else if ( BaseHitObject is Slider )
2017-02-20 00:41:51 +08:00
{
switch ( type )
{
case OsuDifficultyCalculator . DifficultyType . Speed :
// For speed strain we treat the whole slider as a single spacing entity, since "Speed" is about how hard it is to click buttons fast.
// The spacing weight exists to differentiate between being able to easily alternate or having to single.
addition =
spacingWeight ( previousHitObject . lazySliderLength +
DistanceTo ( previousHitObject ) , type ) *
2017-02-20 01:11:14 +08:00
spacing_weight_scaling [ ( int ) type ] ;
2017-02-20 00:41:51 +08:00
break ;
case OsuDifficultyCalculator . DifficultyType . Aim :
// For Aim strain we treat each slider segment and the jump after the end of the slider as separate jumps, since movement-wise there is no difference
// to multiple jumps.
addition =
(
spacingWeight ( previousHitObject . lazySliderLength , type ) +
spacingWeight ( DistanceTo ( previousHitObject ) , type )
) *
2017-02-20 01:11:14 +08:00
spacing_weight_scaling [ ( int ) type ] ;
2017-02-20 00:41:51 +08:00
break ;
}
}
2017-03-20 12:10:24 +08:00
else if ( BaseHitObject is HitCircle )
2017-02-20 00:41:51 +08:00
{
2017-02-20 01:11:14 +08:00
addition = spacingWeight ( DistanceTo ( previousHitObject ) , type ) * spacing_weight_scaling [ ( int ) type ] ;
2017-02-20 00:41:51 +08:00
}
// Scale addition by the time, that elapsed. Filter out HitObjects that are too close to be played anyway to avoid crazy values by division through close to zero.
// You will never find maps that require this amongst ranked maps.
addition / = Math . Max ( timeElapsed , 50 ) ;
Strains [ ( int ) type ] = previousHitObject . Strains [ ( int ) type ] * decay + addition ;
}
internal double DistanceTo ( OsuHitObjectDifficulty other )
{
// Scale the distance by circle size.
2017-02-20 01:21:45 +08:00
return ( startPosition - other . endPosition ) . Length * scalingFactor ;
2017-02-20 00:41:51 +08:00
}
}
}