// Copyright (c) 2007-2017 ppy Pty Ltd . // 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.Rulesets.Osu.Objects { internal class OsuHitObjectDifficulty { /// /// Factor by how much speed / aim strain decays per second. /// /// /// These values are results of tweaking a lot and taking into account general feedback. /// Opinionated observation: Speed is easier to maintain than accurate jumps. /// internal static readonly double[] DECAY_BASE = { 0.3, 0.15 }; /// /// Pseudo threshold values to distinguish between "singles" and "streams" /// /// /// 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. /// private const double stream_spacing_threshold = 110, single_spacing_threshold = 125; /// /// Scaling values for weightings to keep aim and speed difficulty in balance. /// /// /// Found from testing a very large map pool (containing all ranked maps) and keeping the average values the same. /// private static readonly double[] spacing_weight_scaling = { 1400, 26.25 }; /// /// Almost the normed diameter of a circle (104 osu pixel). That is -after- position transforming. /// private const double almost_diameter = 90; internal OsuHitObject BaseHitObject; internal double[] Strains = { 1, 1 }; internal int MaxCombo = 1; private readonly float scalingFactor; private float lazySliderLength; private readonly Vector2 startPosition; private readonly Vector2 endPosition; 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. scalingFactor = 52.0f / circleRadius; if (circleRadius < 30) { float smallCircleBonus = Math.Min(30.0f - circleRadius, 5.0f) / 50.0f; scalingFactor *= 1.0f + smallCircleBonus; } lazySliderLength = 0; startPosition = baseHitObject.StackedPosition; // 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 Vector2 cursorPos = startPosition; Action 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; endPosition = cursorPos; } // We have a normal HitCircle or a spinner else endPosition = startPosition; } 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: if (distance > single_spacing_threshold) return 2.5; 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) return 0.95 + 0.25 * (distance - almost_diameter / 2) / (almost_diameter / 2); 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); if (BaseHitObject is Spinner) { // Do nothing for spinners } else if (BaseHitObject is Slider) { 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) * spacing_weight_scaling[(int)type]; 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) ) * spacing_weight_scaling[(int)type]; break; } } else if (BaseHitObject is HitCircle) { addition = spacingWeight(DistanceTo(previousHitObject), type) * spacing_weight_scaling[(int)type]; } // 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. return (startPosition - other.endPosition).Length * scalingFactor; } } }