diff --git a/osu.Game/Beatmaps/BeatmapDifficultyManager.cs b/osu.Game/Beatmaps/BeatmapDifficultyManager.cs new file mode 100644 index 0000000000..5e644fbf1c --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapDifficultyManager.cs @@ -0,0 +1,278 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Lists; +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)); + + // A permanent cache to prevent re-computations. + private readonly ConcurrentDictionary difficultyCache = new ConcurrentDictionary(); + + // All bindables that should be updated along with the current ruleset + mods. + private readonly LockedWeakList trackedBindables = new LockedWeakList(); + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + [Resolved] + private Bindable currentRuleset { get; set; } + + [Resolved] + private Bindable> currentMods { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentRuleset.BindValueChanged(_ => updateTrackedBindables()); + currentMods.BindValueChanged(_ => updateTrackedBindables(), true); + } + + /// + /// Retrieves a bindable containing the star difficulty of a that follows the currently-selected ruleset and mods. + /// + /// The to get the difficulty of. + /// An optional which stops updating the star difficulty for the given . + /// A bindable that is updated to contain the star difficulty when it becomes available. + public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) + { + var bindable = createBindable(beatmapInfo, currentRuleset.Value, currentMods.Value, cancellationToken); + trackedBindables.Add(bindable); + return bindable; + } + + /// + /// Retrieves a bindable containing the star difficulty of a with a given and combination. + /// + /// + /// The bindable will not update to follow the currently-selected ruleset and mods. + /// + /// The to get the difficulty of. + /// The to get the difficulty with. If null, the 's ruleset is used. + /// The s to get the difficulty with. If null, no mods will be assumed. + /// An optional which stops updating the star difficulty for the given . + /// A bindable that is updated to contain the star difficulty when it becomes available. + public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, + CancellationToken cancellationToken = default) + => createBindable(beatmapInfo, rulesetInfo, mods, cancellationToken); + + /// + /// Retrieves the difficulty of a . + /// + /// The to get the difficulty of. + /// The to get the difficulty with. + /// The s to get the difficulty with. + /// An optional which stops computing the star difficulty. + /// The . + public async Task GetDifficultyAsync([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo = null, [CanBeNull] IEnumerable mods = null, + CancellationToken cancellationToken = default) + { + if (tryGetExisting(beatmapInfo, rulesetInfo, mods, out var existing, out var key)) + return existing; + + return await Task.Factory.StartNew(() => computeDifficulty(key, beatmapInfo, rulesetInfo), cancellationToken, + TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); + } + + /// + /// Retrieves the difficulty of a . + /// + /// The to get the difficulty of. + /// The to get the difficulty with. + /// The s to get the difficulty with. + /// The . + public StarDifficulty GetDifficulty([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo = null, [CanBeNull] IEnumerable mods = null) + { + if (tryGetExisting(beatmapInfo, rulesetInfo, mods, out var existing, out var key)) + return existing; + + return computeDifficulty(key, beatmapInfo, rulesetInfo); + } + + private CancellationTokenSource trackedUpdateCancellationSource; + + /// + /// Updates all tracked using the current ruleset and mods. + /// + private void updateTrackedBindables() + { + trackedUpdateCancellationSource?.Cancel(); + trackedUpdateCancellationSource = new CancellationTokenSource(); + + foreach (var b in trackedBindables) + { + if (trackedUpdateCancellationSource.IsCancellationRequested) + break; + + using (var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(trackedUpdateCancellationSource.Token, b.CancellationToken)) + updateBindable(b, currentRuleset.Value, currentMods.Value, linkedSource.Token); + } + } + + /// + /// Updates the value of a with a given ruleset + mods. + /// + /// The to update. + /// The to update with. + /// The s to update with. + /// A token that may be used to cancel this update. + private void updateBindable([NotNull] BindableStarDifficulty bindable, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, CancellationToken cancellationToken = default) + { + 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); + } + + /// + /// Creates a new and triggers an initial value update. + /// + /// The that star difficulty should correspond to. + /// The initial to get the difficulty with. + /// The initial s to get the difficulty with. + /// An optional which stops updating the star difficulty for the given . + /// The . + private BindableStarDifficulty createBindable([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo initialRulesetInfo, [CanBeNull] IEnumerable initialMods, + CancellationToken cancellationToken) + { + var bindable = new BindableStarDifficulty(beatmapInfo, cancellationToken); + updateBindable(bindable, initialRulesetInfo, initialMods, cancellationToken); + return bindable; + } + + /// + /// Computes the difficulty defined by a key, and stores it to the timed cache. + /// + /// The that defines the computation parameters. + /// The to compute the difficulty of. + /// The to compute the difficulty with. + /// The . + private StarDifficulty computeDifficulty(in DifficultyCacheLookup key, BeatmapInfo beatmapInfo, RulesetInfo rulesetInfo) + { + // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. + rulesetInfo ??= beatmapInfo.Ruleset; + + try + { + var ruleset = rulesetInfo.CreateInstance(); + Debug.Assert(ruleset != null); + + var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(beatmapInfo)); + var attributes = calculator.Calculate(key.Mods); + + return difficultyCache[key] = new StarDifficulty(attributes.StarRating); + } + catch + { + return difficultyCache[key] = new StarDifficulty(0); + } + } + + /// + /// Attempts to retrieve an existing difficulty for the combination. + /// + /// The . + /// The . + /// The s. + /// The existing difficulty value, if present. + /// The key that was used to perform this lookup. This can be further used to query . + /// Whether an existing difficulty was found. + private bool tryGetExisting(BeatmapInfo beatmapInfo, RulesetInfo rulesetInfo, IEnumerable mods, out StarDifficulty existingDifficulty, out DifficultyCacheLookup key) + { + // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. + rulesetInfo ??= beatmapInfo.Ruleset; + + // Difficulty can only be computed if the beatmap and ruleset are locally available. + if (beatmapInfo.ID == 0 || rulesetInfo.ID == null) + { + // If not, fall back to the existing star difficulty (e.g. from an online source). + existingDifficulty = new StarDifficulty(beatmapInfo.StarDifficulty); + key = default; + + return true; + } + + key = new DifficultyCacheLookup(beatmapInfo.ID, rulesetInfo.ID.Value, mods); + return difficultyCache.TryGetValue(key, out existingDifficulty); + } + + private readonly struct DifficultyCacheLookup : IEquatable + { + public readonly int BeatmapId; + public readonly int RulesetId; + public readonly Mod[] Mods; + + public DifficultyCacheLookup(int beatmapId, int rulesetId, IEnumerable mods) + { + BeatmapId = beatmapId; + RulesetId = rulesetId; + Mods = mods?.OrderBy(m => m.Acronym).ToArray() ?? Array.Empty(); + } + + public bool Equals(DifficultyCacheLookup other) + => BeatmapId == other.BeatmapId + && RulesetId == other.RulesetId + && Mods.SequenceEqual(other.Mods); + + public override int GetHashCode() + { + var hashCode = new HashCode(); + + hashCode.Add(BeatmapId); + hashCode.Add(RulesetId); + foreach (var mod in Mods) + hashCode.Add(mod.Acronym); + + return hashCode.ToHashCode(); + } + } + + private class BindableStarDifficulty : Bindable + { + 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; + + public StarDifficulty(double stars) + { + Stars = stars; + + // Todo: Add more members (BeatmapInfo.DifficultyRating? Attributes? Etc...) + } + } +} diff --git a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs index b7ea1ba56a..3015c44613 100644 --- a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs +++ b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -55,7 +54,16 @@ namespace osu.Game.Graphics.Cursor return; } - var newTarget = inputManager.HoveredDrawables.OfType().FirstOrDefault(t => t.ProvidingUserCursor) ?? this; + IProvideCursor newTarget = this; + + foreach (var d in inputManager.HoveredDrawables) + { + if (d is IProvideCursor p && p.ProvidingUserCursor) + { + newTarget = p; + break; + } + } if (currentTarget == newTarget) return; diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index 9804aefce8..d1f6fd445e 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -19,6 +19,7 @@ using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; namespace osu.Game.Graphics { @@ -119,7 +120,9 @@ namespace osu.Game.Graphics break; case ScreenshotFormat.Jpg: - image.SaveAsJpeg(stream); + const int jpeg_quality = 92; + + image.SaveAsJpeg(stream, new JpegEncoder { Quality = jpeg_quality }); break; default: diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index f4bb10340e..d6a07651e2 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -18,6 +18,7 @@ using osu.Game.Screens.Menu; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Humanizer; using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -759,7 +760,7 @@ namespace osu.Game Schedule(() => notifications.Post(new SimpleNotification { Icon = entry.Level == LogLevel.Important ? FontAwesome.Solid.ExclamationCircle : FontAwesome.Solid.Bomb, - Text = entry.Message + (entry.Exception != null && IsDeployedBuild ? "\n\nThis error has been automatically reported to the devs." : string.Empty), + Text = entry.Message.Truncate(256) + (entry.Exception != null && IsDeployedBuild ? "\n\nThis error has been automatically reported to the devs." : string.Empty), })); } else if (recentLogCount == short_term_display_limit) diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index dd120937af..fe5c0704b7 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -199,6 +199,10 @@ namespace osu.Game ScoreManager.Undelete(getBeatmapScores(item), true); }); + var difficultyManager = new BeatmapDifficultyManager(); + dependencies.Cache(difficultyManager); + AddInternal(difficultyManager); + dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore)); dependencies.Cache(SettingsStore = new SettingsStore(contextFactory)); dependencies.Cache(RulesetConfigCache = new RulesetConfigCache(SettingsStore)); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index 3e4798a812..c559b4f8f5 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.Threading; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -41,6 +43,12 @@ namespace osu.Game.Screens.Select.Carousel [Resolved(CanBeNull = true)] private BeatmapSetOverlay beatmapOverlay { get; set; } + [Resolved] + private BeatmapDifficultyManager difficultyManager { get; set; } + + private IBindable starDifficultyBindable; + private CancellationTokenSource starDifficultyCancellationSource; + public DrawableCarouselBeatmap(CarouselBeatmap panel) : base(panel) { @@ -137,7 +145,6 @@ namespace osu.Game.Screens.Select.Carousel }, starCounter = new StarCounter { - Current = (float)beatmap.StarDifficulty, Scale = new Vector2(0.8f), } } @@ -181,6 +188,16 @@ namespace osu.Game.Screens.Select.Carousel if (Item.State.Value != CarouselItemState.Collapsed && Alpha == 0) starCounter.ReplayAnimation(); + starDifficultyCancellationSource?.Cancel(); + + // Only compute difficulty when the item is visible. + if (Item.State.Value != CarouselItemState.Collapsed) + { + // We've potentially cancelled the computation above so a new bindable is required. + starDifficultyBindable = difficultyManager.GetBindableDifficulty(beatmap, (starDifficultyCancellationSource = new CancellationTokenSource()).Token); + starDifficultyBindable.BindValueChanged(d => starCounter.Current = (float)d.NewValue.Stars, true); + } + base.ApplyState(); } @@ -205,5 +222,11 @@ namespace osu.Game.Screens.Select.Carousel return items.ToArray(); } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + starDifficultyCancellationSource?.Cancel(); + } } } diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index 02822ea608..44c328187f 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -14,10 +14,12 @@ using osu.Framework.Bindables; using System.Collections.Generic; using osu.Game.Rulesets.Mods; using System.Linq; +using System.Threading; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Overlays.Settings; +using osu.Game.Rulesets; namespace osu.Game.Screens.Select.Details { @@ -26,6 +28,12 @@ namespace osu.Game.Screens.Select.Details [Resolved] private IBindable> mods { get; set; } + [Resolved] + private IBindable ruleset { get; set; } + + [Resolved] + private BeatmapDifficultyManager difficultyManager { get; set; } + protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate; private readonly StatisticRow starDifficulty; @@ -71,6 +79,7 @@ namespace osu.Game.Screens.Select.Details { base.LoadComplete(); + ruleset.BindValueChanged(_ => updateStatistics()); mods.BindValueChanged(modsChanged, true); } @@ -132,11 +141,39 @@ namespace osu.Game.Screens.Select.Details break; } - starDifficulty.Value = ((float)(Beatmap?.StarDifficulty ?? 0), null); - HpDrain.Value = (baseDifficulty?.DrainRate ?? 0, adjustedDifficulty?.DrainRate); Accuracy.Value = (baseDifficulty?.OverallDifficulty ?? 0, adjustedDifficulty?.OverallDifficulty); ApproachRate.Value = (baseDifficulty?.ApproachRate ?? 0, adjustedDifficulty?.ApproachRate); + + updateStarDifficulty(); + } + + private IBindable normalStarDifficulty; + private IBindable moddedStarDifficulty; + private CancellationTokenSource starDifficultyCancellationSource; + + private void updateStarDifficulty() + { + starDifficultyCancellationSource?.Cancel(); + + if (Beatmap == null) + return; + + starDifficultyCancellationSource = new CancellationTokenSource(); + + normalStarDifficulty = difficultyManager.GetBindableDifficulty(Beatmap, ruleset.Value, null, starDifficultyCancellationSource.Token); + moddedStarDifficulty = difficultyManager.GetBindableDifficulty(Beatmap, ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); + + normalStarDifficulty.BindValueChanged(_ => updateDisplay()); + moddedStarDifficulty.BindValueChanged(_ => updateDisplay(), true); + + void updateDisplay() => starDifficulty.Value = ((float)normalStarDifficulty.Value.Stars, (float)moddedStarDifficulty.Value.Stars); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + starDifficultyCancellationSource?.Cancel(); } public class StatisticRow : Container, IHasAccentColour