diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 83538a2f42..de399cc6a9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -1,6 +1,7 @@ // 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.Generic; using System.Linq; using JetBrains.Annotations; @@ -8,6 +9,7 @@ using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Difficulty.Skills; namespace osu.Game.Rulesets.Osu.Difficulty { @@ -121,6 +123,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty SpinnerCount = onlineInfo.SpinnerCount; } + public override SkillValue[] GetSkillValues() + { + double aimPerformanceWithoutSliders = Math.Pow(OsuStrainSkill.DifficultyToPerformance(AimDifficulty * SliderFactor), 2); + double aimPerformanceOnlySliders = Math.Pow(OsuStrainSkill.DifficultyToPerformance(AimDifficulty), 2) - aimPerformanceWithoutSliders; + + double speedPerformance = Math.Pow(OsuStrainSkill.DifficultyToPerformance(SpeedDifficulty), 2); + double flashlightPerformance = Math.Pow(Flashlight.DifficultyToPerformance(FlashlightDifficulty), 2); + + return [ + new SkillValue { Value = aimPerformanceOnlySliders, SkillName = "Slider Aim" }, + new SkillValue { Value = aimPerformanceWithoutSliders, SkillName = "Aim" }, + new SkillValue { Value = speedPerformance, SkillName = "Speed" }, + new SkillValue { Value = flashlightPerformance, SkillName = "Flashlight" } + ]; + } + #region Newtonsoft.Json implicit ShouldSerialize() methods // The properties in this region are used implicitly by Newtonsoft.Json to not serialise certain fields in some cases. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index 1664c941f8..eba17afbed 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -1,6 +1,7 @@ // 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.Generic; using Newtonsoft.Json; using osu.Game.Beatmaps; @@ -59,5 +60,22 @@ namespace osu.Game.Rulesets.Taiko.Difficulty StarRating = values[ATTRIB_ID_DIFFICULTY]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; } + + public override SkillValue[] GetSkillValues() + { + //double aimPerformanceWithoutSliders = OsuStrainSkill.DifficultyToPerformance(AimDifficulty * SliderFactor); + //double speedPerformance = OsuStrainSkill.DifficultyToPerformance(SpeedDifficulty); + //double flashlightPerformance = Flashlight.DifficultyToPerformance(FlashlightDifficulty); + + + + return [ + new SkillValue { Value = difficultyRescale(ColourDifficulty), SkillName = "Colour" }, + new SkillValue { Value = difficultyRescale(RhythmDifficulty), SkillName = "Rhythm" }, + new SkillValue { Value = difficultyRescale(StaminaDifficulty), SkillName = "Stamina" }, + ]; + } + + private static double difficultyRescale(double difficulty) => 10.43 * Math.Log(difficulty * 1.4 / 8 + 1); } } diff --git a/osu.Game.Tests/Visual/Components/TestSceneSkillsBreakdown.cs b/osu.Game.Tests/Visual/Components/TestSceneSkillsBreakdown.cs new file mode 100644 index 0000000000..80428428d5 --- /dev/null +++ b/osu.Game.Tests/Visual/Components/TestSceneSkillsBreakdown.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using NUnit.Framework; +using osu.Framework.Testing; +using osuTK.Graphics; +using osu.Game.Skinning.Components; +using System; +using System.Linq; +using System.Threading.Tasks; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using System.Threading; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Skinning; +using System.Collections.Generic; + +namespace osu.Game.Tests.Visual.Components +{ + public partial class TestSceneSkillsBreakdown : OsuTestScene + { + private TestBeatmapDifficultyCache difficultyCache = null!; + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.CacheAs(difficultyCache = new TestBeatmapDifficultyCache()); + + AddRange(new Drawable[] + { + difficultyCache, + new SkillsBreakdown + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new osuTK.Vector2(250) + } + }); + } + + [Test] + public void TestEqualValues() + { + AddStep("1", () => + { + difficultyCache.UseRandomAttributes = false; + triggerCacheRecalc(); + }); + } + + [Test] + public void TestRandomValues() + { + AddStep("2", () => + { + difficultyCache.UseRandomAttributes = true; + triggerCacheRecalc(); + }); + } + + private void triggerCacheRecalc() + { + // Invalidate cache to force recalc + difficultyCache.Invalidate(Beatmap.Value.BeatmapInfo); + + // Change mods to trigger ValueChanged + SelectedMods.Value = SelectedMods.Value.Count >= 0 ? new List() : SelectedMods.Value.Append(new OsuModNoFail()).ToList(); + } + + private partial class TestBeatmapDifficultyCache : BeatmapDifficultyCache + { + public bool UseRandomAttributes; + private Random random = new Random(); + + protected override Task ComputeValueAsync(DifficultyCacheLookup lookup, CancellationToken token = default) + { + var attributes = new TestDifficultyAttributes( + Enumerable.Range(0, 4) + .Select(_ => UseRandomAttributes ? random.NextDouble() : 1.0) + .ToArray()); + + return Task.FromResult(new StarDifficulty(attributes)); + } + } + + private partial class TestDifficultyAttributes : DifficultyAttributes + { + private SkillValue[] values; + + public TestDifficultyAttributes(double[] values) + { + this.values = new SkillValue[values.Length]; + + for (int i = 0; i < values.Length; i++) + { + this.values[i] = new SkillValue { Value = values[i], SkillName = $"Test Skill {i + 1}" }; + } + } + public override SkillValue[] GetSkillValues() => values; + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 9690924b1c..be3af47131 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -82,5 +83,13 @@ namespace osu.Game.Rulesets.Difficulty { MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; } + + public virtual SkillValue[] GetSkillValues() => []; + } + + public struct SkillValue + { + public double Value; + public LocalisableString SkillName; } } diff --git a/osu.Game/Skinning/SkillsBreakdown.cs b/osu.Game/Skinning/SkillsBreakdown.cs new file mode 100644 index 0000000000..0b1a3070fb --- /dev/null +++ b/osu.Game/Skinning/SkillsBreakdown.cs @@ -0,0 +1,190 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using System; +using System.Linq; +using System.Threading; +using osuTK; +using osuTK.Graphics; +using osu.Framework.Allocation; +using osu.Game.Beatmaps; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; +using osu.Framework.Layout; + +namespace osu.Game.Skinning +{ + [UsedImplicitly] + public partial class SkillsBreakdown : CompositeDrawable, ISerialisableDrawable + { + public bool UsesFixedAnchor { get; set; } + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; + private IBindable starDifficulty = null!; + private CancellationTokenSource? cancellationSource; + + private Color4[] colors = null!; + private Container circles = null!; + + public SkillsBreakdown() + { + Size = new Vector2(50); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colorSource) + { + InternalChild = circles = new Container + { + RelativeSizeAxes = Axes.Both + }; + + colors = new Color4[] + { + colorSource.Blue3, + colorSource.Lime3, + colorSource.Red3, + colorSource.YellowDark + }; + + foreach (var color in colors) + circles.Add(new SkillCircle(color, 1, 1)); + + circles.First().SetProgress(0, 1); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.BindValueChanged(b => + { + cancellationSource?.Cancel(); + starDifficulty = difficultyCache.GetBindableDifficulty(b.NewValue.BeatmapInfo, (cancellationSource = new CancellationTokenSource()).Token); + starDifficulty.BindValueChanged(d => updateSkillsBreakdown(d.NewValue)); + }, true); + } + + private void updateSkillsBreakdown(StarDifficulty? starDifficulty) + { + if (starDifficulty == null) + return; + + var skillNameValues = starDifficulty.Value.Attributes.GetSkillValues(); + + // Square the values to make visual representation more intuitive + double[] skillValues = skillNameValues.Select(x => Math.Pow(x.Value, 1)).ToArray(); + LocalisableString[] skillNames = skillNameValues.Select(x => x.SkillName).ToArray(); + + double sum = skillValues.Sum(); + if (sum == 0) sum = 1; + + double[] skillValuesNormalized = skillValues.Select(x => x / sum).ToArray(); + + double cumulativeValue = 0; + for (int i = 0; i < circles.Count; i++) + { + double nextCumulativeValue = i < skillValuesNormalized.Length ? cumulativeValue + skillValuesNormalized[i] : 1; + + circles[i].SetProgress(Math.Round(cumulativeValue, 5), Math.Round(nextCumulativeValue, 5)); + circles[i].SkillName = i < skillNames.Length ? skillNames[i] : ""; + + cumulativeValue = nextCumulativeValue; + } + } + + private partial class SkillCircle : Container + { + public LocalisableString SkillName { set => innerCircle.SkillName = value; } + + // Same as StarRatingDisplay + private const double animation_duration = 700; + private const Easing animation_type = Easing.OutQuint; + + private TooltipCircularProgress innerCircle; + public SkillCircle(Color4 color, double startProgress, double endProgress) + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Child = innerCircle = new TooltipCircularProgress + { + InnerRadius = 0.5f, + Colour = color, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both + }; + + SetProgress(startProgress, endProgress); + } + + public void SetProgress(double startProgress, double endProgress) + { + this.RotateTo((float)startProgress * 360, animation_duration, animation_type); + innerCircle.ProgressTo(endProgress - startProgress, animation_duration, animation_type); + } + + private partial class TooltipCircularProgress : CircularProgress, IHasTooltip + { + public LocalisableString SkillName; + LocalisableString IHasTooltip.TooltipText => SkillName; + + protected override void LoadComplete() + { + base.LoadComplete(); + precalcInputData(); + } + + protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) + { + bool result = base.OnInvalidate(invalidation, source); + precalcInputData(); + return result; + } + + private void precalcInputData() + { + center = ScreenSpaceDrawQuad.Centre; + + float radius = ScreenSpaceDrawQuad.Width / 2; + innerDistanceSquared = MathF.Pow(radius * InnerRadius, 2); + outerDistanceSquared = MathF.Pow(radius, 2); + + startAngle = Parent.Rotation * MathF.PI / 180; + endAngle = startAngle + (float)Progress * 2 * MathF.PI; + } + + private Vector2 center; + private double innerDistanceSquared, outerDistanceSquared; + private float startAngle, endAngle; + private float deltaAngle; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + Vector2 deltaVector = screenSpacePos - center; + double distanceSquared = deltaVector.X * deltaVector.X + deltaVector.Y * deltaVector.Y; + + if (distanceSquared > outerDistanceSquared || distanceSquared < innerDistanceSquared) + return false; + + deltaAngle = MathF.Atan2(-deltaVector.X, deltaVector.Y) + MathF.PI; + + return deltaAngle > startAngle && deltaAngle < endAngle; + + } + } + } + } +}