2020-07-16 11:38:33 +00:00
// 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 ;
2020-07-21 14:50:54 +00:00
using System.Collections.Concurrent ;
2020-07-16 11:38:33 +00:00
using System.Collections.Generic ;
using System.Diagnostics ;
using System.Linq ;
using System.Threading ;
using System.Threading.Tasks ;
using JetBrains.Annotations ;
using osu.Framework.Allocation ;
2020-07-21 14:13:04 +00:00
using osu.Framework.Bindables ;
2020-07-16 11:38:33 +00:00
using osu.Framework.Graphics.Containers ;
2020-07-21 14:13:04 +00:00
using osu.Framework.Lists ;
2020-07-16 11:38:33 +00:00
using osu.Framework.Threading ;
using osu.Game.Rulesets ;
using osu.Game.Rulesets.Mods ;
namespace osu.Game.Beatmaps
{
public class BeatmapDifficultyManager : CompositeDrawable
{
// Too many simultaneous updates can lead to stutters. One thread seems to work fine for song select display purposes.
private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler ( 1 , nameof ( BeatmapDifficultyManager ) ) ;
2020-07-24 04:38:53 +00:00
// A permanent cache to prevent re-computations.
2020-07-21 14:50:54 +00:00
private readonly ConcurrentDictionary < DifficultyCacheLookup , StarDifficulty > difficultyCache = new ConcurrentDictionary < DifficultyCacheLookup , StarDifficulty > ( ) ;
2020-07-16 11:38:33 +00:00
2020-07-21 14:13:04 +00:00
// All bindables that should be updated along with the current ruleset + mods.
private readonly LockedWeakList < BindableStarDifficulty > trackedBindables = new LockedWeakList < BindableStarDifficulty > ( ) ;
[Resolved]
private BeatmapManager beatmapManager { get ; set ; }
[Resolved]
private Bindable < RulesetInfo > currentRuleset { get ; set ; }
[Resolved]
private Bindable < IReadOnlyList < Mod > > currentMods { get ; set ; }
protected override void LoadComplete ( )
{
base . LoadComplete ( ) ;
currentRuleset . BindValueChanged ( _ = > updateTrackedBindables ( ) ) ;
currentMods . BindValueChanged ( _ = > updateTrackedBindables ( ) , true ) ;
}
/// <summary>
2020-07-24 04:52:43 +00:00
/// Retrieves a bindable containing the star difficulty of a <see cref="BeatmapInfo"/> that follows the currently-selected ruleset and mods.
2020-07-21 14:13:04 +00:00
/// </summary>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to get the difficulty of.</param>
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops updating the star difficulty for the given <see cref="BeatmapInfo"/>.</param>
2020-07-24 04:38:53 +00:00
/// <returns>A bindable that is updated to contain the star difficulty when it becomes available.</returns>
2020-07-24 04:52:43 +00:00
public IBindable < StarDifficulty > GetBindableDifficulty ( [ NotNull ] BeatmapInfo beatmapInfo , CancellationToken cancellationToken = default )
{
var bindable = createBindable ( beatmapInfo , currentRuleset . Value , currentMods . Value , cancellationToken ) ;
trackedBindables . Add ( bindable ) ;
return bindable ;
}
2020-07-21 14:13:04 +00:00
/// <summary>
2020-07-24 04:52:43 +00:00
/// Retrieves a bindable containing the star difficulty of a <see cref="BeatmapInfo"/> with a given <see cref="RulesetInfo"/> and <see cref="Mod"/> combination.
2020-07-21 14:13:04 +00:00
/// </summary>
/// <remarks>
2020-07-24 04:52:43 +00:00
/// The bindable will not update to follow the currently-selected ruleset and mods.
2020-07-21 14:13:04 +00:00
/// </remarks>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to get the difficulty of.</param>
2020-07-24 04:54:47 +00:00
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/> to get the difficulty with. If <c>null</c>, the <paramref name="beatmapInfo"/>'s ruleset is used.</param>
/// <param name="mods">The <see cref="Mod"/>s to get the difficulty with. If <c>null</c>, no mods will be assumed.</param>
2020-07-21 14:13:04 +00:00
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops updating the star difficulty for the given <see cref="BeatmapInfo"/>.</param>
2020-07-24 04:52:43 +00:00
/// <returns>A bindable that is updated to contain the star difficulty when it becomes available.</returns>
2020-07-24 04:54:47 +00:00
public IBindable < StarDifficulty > GetBindableDifficulty ( [ NotNull ] BeatmapInfo beatmapInfo , [ CanBeNull ] RulesetInfo rulesetInfo , [ CanBeNull ] IEnumerable < Mod > mods ,
2020-07-24 04:52:43 +00:00
CancellationToken cancellationToken = default )
= > createBindable ( beatmapInfo , rulesetInfo , mods , cancellationToken ) ;
2020-07-16 11:38:33 +00:00
2020-07-21 14:13:04 +00:00
/// <summary>
/// Retrieves the difficulty of a <see cref="BeatmapInfo"/>.
/// </summary>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to get the difficulty of.</param>
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/> to get the difficulty with.</param>
/// <param name="mods">The <see cref="Mod"/>s to get the difficulty with.</param>
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops computing the star difficulty.</param>
/// <returns>The <see cref="StarDifficulty"/>.</returns>
2020-07-24 04:40:01 +00:00
public async Task < StarDifficulty > GetDifficultyAsync ( [ NotNull ] BeatmapInfo beatmapInfo , [ CanBeNull ] RulesetInfo rulesetInfo = null , [ CanBeNull ] IEnumerable < Mod > mods = null ,
2020-07-21 14:13:04 +00:00
CancellationToken cancellationToken = default )
2020-07-16 11:38:33 +00:00
{
2020-07-24 04:38:53 +00:00
if ( tryGetExisting ( beatmapInfo , rulesetInfo , mods , out var existing , out var key ) )
2020-07-16 12:07:14 +00:00
return existing ;
2020-07-16 11:38:33 +00:00
2020-08-28 13:08:28 +00:00
return await Task . Factory . StartNew ( ( ) = >
{
// Computation may have finished in a previous task.
if ( tryGetExisting ( beatmapInfo , rulesetInfo , mods , out existing , out _ ) )
return existing ;
return computeDifficulty ( key , beatmapInfo , rulesetInfo ) ;
} , cancellationToken , TaskCreationOptions . HideScheduler | TaskCreationOptions . RunContinuationsAsynchronously , updateScheduler ) ;
2020-07-16 12:07:14 +00:00
}
2020-07-16 11:38:33 +00:00
2020-07-21 14:13:04 +00:00
/// <summary>
/// Retrieves the difficulty of a <see cref="BeatmapInfo"/>.
/// </summary>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to get the difficulty of.</param>
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/> to get the difficulty with.</param>
/// <param name="mods">The <see cref="Mod"/>s to get the difficulty with.</param>
/// <returns>The <see cref="StarDifficulty"/>.</returns>
2020-07-24 04:40:01 +00:00
public StarDifficulty GetDifficulty ( [ NotNull ] BeatmapInfo beatmapInfo , [ CanBeNull ] RulesetInfo rulesetInfo = null , [ CanBeNull ] IEnumerable < Mod > mods = null )
2020-07-16 12:07:14 +00:00
{
2020-07-24 04:38:53 +00:00
if ( tryGetExisting ( beatmapInfo , rulesetInfo , mods , out var existing , out var key ) )
2020-07-16 11:38:33 +00:00
return existing ;
2020-07-21 14:50:54 +00:00
return computeDifficulty ( key , beatmapInfo , rulesetInfo ) ;
2020-07-16 12:07:14 +00:00
}
2020-10-01 11:50:47 +00:00
/// <summary>
/// Retrieves the <see cref="DifficultyRating"/> that describes a star rating.
/// </summary>
/// <remarks>
/// For more information, see: https://osu.ppy.sh/help/wiki/Difficulties
/// </remarks>
/// <param name="starRating">The star rating.</param>
/// <returns>The <see cref="DifficultyRating"/> that best describes <paramref name="starRating"/>.</returns>
public static DifficultyRating GetDifficultyRating ( double starRating )
{
if ( starRating < 2.0 ) return DifficultyRating . Easy ;
if ( starRating < 2.7 ) return DifficultyRating . Normal ;
if ( starRating < 4.0 ) return DifficultyRating . Hard ;
if ( starRating < 5.3 ) return DifficultyRating . Insane ;
if ( starRating < 6.5 ) return DifficultyRating . Expert ;
return DifficultyRating . ExpertPlus ;
}
2020-07-21 14:13:04 +00:00
private CancellationTokenSource trackedUpdateCancellationSource ;
2020-07-28 07:52:07 +00:00
private readonly List < CancellationTokenSource > linkedCancellationSources = new List < CancellationTokenSource > ( ) ;
2020-07-21 14:13:04 +00:00
/// <summary>
/// Updates all tracked <see cref="BindableStarDifficulty"/> using the current ruleset and mods.
/// </summary>
private void updateTrackedBindables ( )
{
2020-07-28 07:52:07 +00:00
cancelTrackedBindableUpdate ( ) ;
2020-07-21 14:13:04 +00:00
trackedUpdateCancellationSource = new CancellationTokenSource ( ) ;
foreach ( var b in trackedBindables )
{
2020-07-28 07:52:07 +00:00
var linkedSource = CancellationTokenSource . CreateLinkedTokenSource ( trackedUpdateCancellationSource . Token , b . CancellationToken ) ;
linkedCancellationSources . Add ( linkedSource ) ;
updateBindable ( b , currentRuleset . Value , currentMods . Value , linkedSource . Token ) ;
}
}
/// <summary>
/// Cancels the existing update of all tracked <see cref="BindableStarDifficulty"/> via <see cref="updateTrackedBindables"/>.
/// </summary>
private void cancelTrackedBindableUpdate ( )
{
trackedUpdateCancellationSource ? . Cancel ( ) ;
trackedUpdateCancellationSource = null ;
2020-07-21 14:13:04 +00:00
2020-07-29 02:30:25 +00:00
if ( linkedCancellationSources ! = null )
{
foreach ( var c in linkedCancellationSources )
c . Dispose ( ) ;
2020-07-28 07:52:07 +00:00
2020-07-29 02:30:25 +00:00
linkedCancellationSources . Clear ( ) ;
}
2020-07-21 14:13:04 +00:00
}
2020-07-28 07:52:19 +00:00
/// <summary>
/// Creates a new <see cref="BindableStarDifficulty"/> and triggers an initial value update.
/// </summary>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> that star difficulty should correspond to.</param>
/// <param name="initialRulesetInfo">The initial <see cref="RulesetInfo"/> to get the difficulty with.</param>
/// <param name="initialMods">The initial <see cref="Mod"/>s to get the difficulty with.</param>
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops updating the star difficulty for the given <see cref="BeatmapInfo"/>.</param>
/// <returns>The <see cref="BindableStarDifficulty"/>.</returns>
private BindableStarDifficulty createBindable ( [ NotNull ] BeatmapInfo beatmapInfo , [ CanBeNull ] RulesetInfo initialRulesetInfo , [ CanBeNull ] IEnumerable < Mod > initialMods ,
CancellationToken cancellationToken )
{
var bindable = new BindableStarDifficulty ( beatmapInfo , cancellationToken ) ;
updateBindable ( bindable , initialRulesetInfo , initialMods , cancellationToken ) ;
return bindable ;
}
2020-07-21 14:13:04 +00:00
/// <summary>
/// Updates the value of a <see cref="BindableStarDifficulty"/> with a given ruleset + mods.
/// </summary>
/// <param name="bindable">The <see cref="BindableStarDifficulty"/> to update.</param>
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/> to update with.</param>
/// <param name="mods">The <see cref="Mod"/>s to update with.</param>
/// <param name="cancellationToken">A token that may be used to cancel this update.</param>
2020-07-24 04:40:01 +00:00
private void updateBindable ( [ NotNull ] BindableStarDifficulty bindable , [ CanBeNull ] RulesetInfo rulesetInfo , [ CanBeNull ] IEnumerable < Mod > mods , CancellationToken cancellationToken = default )
2020-07-21 14:13:04 +00:00
{
GetDifficultyAsync ( bindable . Beatmap , rulesetInfo , mods , cancellationToken ) . ContinueWith ( t = >
{
// We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events.
Schedule ( ( ) = >
{
if ( ! cancellationToken . IsCancellationRequested )
bindable . Value = t . Result ;
} ) ;
} , cancellationToken ) ;
}
/// <summary>
/// Computes the difficulty defined by a <see cref="DifficultyCacheLookup"/> key, and stores it to the timed cache.
/// </summary>
/// <param name="key">The <see cref="DifficultyCacheLookup"/> that defines the computation parameters.</param>
2020-07-21 14:50:54 +00:00
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to compute the difficulty of.</param>
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/> to compute the difficulty with.</param>
2020-07-21 14:13:04 +00:00
/// <returns>The <see cref="StarDifficulty"/>.</returns>
2020-07-21 14:50:54 +00:00
private StarDifficulty computeDifficulty ( in DifficultyCacheLookup key , BeatmapInfo beatmapInfo , RulesetInfo rulesetInfo )
2020-07-16 12:07:14 +00:00
{
2020-07-22 03:48:12 +00:00
// In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset.
rulesetInfo ? ? = beatmapInfo . Ruleset ;
2020-07-16 11:38:33 +00:00
try
{
2020-07-21 14:50:54 +00:00
var ruleset = rulesetInfo . CreateInstance ( ) ;
2020-07-16 11:38:33 +00:00
Debug . Assert ( ruleset ! = null ) ;
2020-07-21 14:50:54 +00:00
var calculator = ruleset . CreateDifficultyCalculator ( beatmapManager . GetWorkingBeatmap ( beatmapInfo ) ) ;
2020-07-16 12:07:14 +00:00
var attributes = calculator . Calculate ( key . Mods ) ;
2020-07-16 11:38:33 +00:00
2020-08-28 10:16:46 +00:00
return difficultyCache [ key ] = new StarDifficulty ( attributes . StarRating , attributes . MaxCombo ) ;
2020-07-16 11:38:33 +00:00
}
catch
{
2020-08-28 10:16:46 +00:00
return difficultyCache [ key ] = new StarDifficulty ( ) ;
2020-07-16 11:38:33 +00:00
}
}
2020-07-16 12:07:14 +00:00
/// <summary>
/// Attempts to retrieve an existing difficulty for the combination.
/// </summary>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/>.</param>
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/>.</param>
/// <param name="mods">The <see cref="Mod"/>s.</param>
/// <param name="existingDifficulty">The existing difficulty value, if present.</param>
2020-07-21 14:13:04 +00:00
/// <param name="key">The <see cref="DifficultyCacheLookup"/> key that was used to perform this lookup. This can be further used to query <see cref="computeDifficulty"/>.</param>
2020-07-16 12:07:14 +00:00
/// <returns>Whether an existing difficulty was found.</returns>
2020-07-24 04:40:01 +00:00
private bool tryGetExisting ( BeatmapInfo beatmapInfo , RulesetInfo rulesetInfo , IEnumerable < Mod > mods , out StarDifficulty existingDifficulty , out DifficultyCacheLookup key )
2020-07-16 12:07:14 +00:00
{
// In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset.
rulesetInfo ? ? = beatmapInfo . Ruleset ;
2020-07-21 14:50:54 +00:00
// Difficulty can only be computed if the beatmap and ruleset are locally available.
if ( beatmapInfo . ID = = 0 | | rulesetInfo . ID = = null )
2020-07-16 12:07:14 +00:00
{
2020-07-22 03:47:53 +00:00
// If not, fall back to the existing star difficulty (e.g. from an online source).
2020-08-28 10:16:46 +00:00
existingDifficulty = new StarDifficulty ( beatmapInfo . StarDifficulty , beatmapInfo . MaxCombo ? ? 0 ) ;
2020-07-16 12:07:14 +00:00
key = default ;
return true ;
}
2020-07-21 14:50:54 +00:00
key = new DifficultyCacheLookup ( beatmapInfo . ID , rulesetInfo . ID . Value , mods ) ;
2020-07-16 12:07:14 +00:00
return difficultyCache . TryGetValue ( key , out existingDifficulty ) ;
}
2020-07-28 07:52:07 +00:00
protected override void Dispose ( bool isDisposing )
{
base . Dispose ( isDisposing ) ;
2020-07-28 08:23:35 +00:00
2020-07-28 07:52:07 +00:00
cancelTrackedBindableUpdate ( ) ;
2020-07-29 02:30:25 +00:00
updateScheduler ? . Dispose ( ) ;
2020-07-28 07:52:07 +00:00
}
2020-08-28 13:12:17 +00:00
public readonly struct DifficultyCacheLookup : IEquatable < DifficultyCacheLookup >
2020-07-16 11:38:33 +00:00
{
2020-07-21 14:50:54 +00:00
public readonly int BeatmapId ;
public readonly int RulesetId ;
2020-07-16 12:07:14 +00:00
public readonly Mod [ ] Mods ;
2020-07-16 11:38:33 +00:00
2020-07-21 14:50:54 +00:00
public DifficultyCacheLookup ( int beatmapId , int rulesetId , IEnumerable < Mod > mods )
2020-07-16 11:38:33 +00:00
{
2020-07-21 14:50:54 +00:00
BeatmapId = beatmapId ;
RulesetId = rulesetId ;
2020-07-16 12:07:14 +00:00
Mods = mods ? . OrderBy ( m = > m . Acronym ) . ToArray ( ) ? ? Array . Empty < Mod > ( ) ;
2020-07-16 11:38:33 +00:00
}
public bool Equals ( DifficultyCacheLookup other )
2020-07-21 14:50:54 +00:00
= > BeatmapId = = other . BeatmapId
& & RulesetId = = other . RulesetId
2020-08-28 13:12:17 +00:00
& & Mods . Select ( m = > m . Acronym ) . SequenceEqual ( other . Mods . Select ( m = > m . Acronym ) ) ;
2020-07-16 11:38:33 +00:00
public override int GetHashCode ( )
{
var hashCode = new HashCode ( ) ;
2020-07-21 14:50:54 +00:00
hashCode . Add ( BeatmapId ) ;
hashCode . Add ( RulesetId ) ;
2020-07-16 12:07:14 +00:00
foreach ( var mod in Mods )
2020-07-16 11:38:33 +00:00
hashCode . Add ( mod . Acronym ) ;
return hashCode . ToHashCode ( ) ;
}
}
2020-07-21 14:13:04 +00:00
private class BindableStarDifficulty : Bindable < StarDifficulty >
{
public readonly BeatmapInfo Beatmap ;
public readonly CancellationToken CancellationToken ;
public BindableStarDifficulty ( BeatmapInfo beatmap , CancellationToken cancellationToken )
{
Beatmap = beatmap ;
CancellationToken = cancellationToken ;
}
}
}
public readonly struct StarDifficulty
{
public readonly double Stars ;
2020-08-28 10:16:46 +00:00
public readonly int MaxCombo ;
2020-07-21 14:13:04 +00:00
2020-08-28 10:16:46 +00:00
public StarDifficulty ( double stars , int maxCombo )
2020-07-21 14:13:04 +00:00
{
Stars = stars ;
2020-08-28 10:16:46 +00:00
MaxCombo = maxCombo ;
2020-07-21 14:13:04 +00:00
// Todo: Add more members (BeatmapInfo.DifficultyRating? Attributes? Etc...)
}
2020-09-30 15:53:01 +00:00
2020-10-01 11:50:47 +00:00
public DifficultyRating DifficultyRating = > BeatmapDifficultyManager . GetDifficultyRating ( Stars ) ;
2020-07-16 11:38:33 +00:00
}
}