diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 0496d1f680..487d81f35b 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -320,16 +320,21 @@ namespace osu.Game.Rulesets.Osu RelativeSizeAxes = Axes.X, Height = 250 }, true), + new StatisticItem("Timing Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[] + { + new AverageHitError(timedHitEvents), + new UnstableRate(timedHitEvents) + }), true), new StatisticItem("Accuracy Heatmap", () => new AccuracyHeatmap(score, playableBeatmap) { RelativeSizeAxes = Axes.X, Height = 250 }, true), - new StatisticItem("Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[] + new StatisticItem("Aim Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[] { - new AverageHitError(timedHitEvents), - new UnstableRate(timedHitEvents) - }), true) + new AverageAimError(timedHitEvents), + new AimError(timedHitEvents) + }), true), }; } diff --git a/osu.Game.Rulesets.Osu/Statistics/AimError.cs b/osu.Game.Rulesets.Osu/Statistics/AimError.cs new file mode 100644 index 0000000000..0d91fff1ad --- /dev/null +++ b/osu.Game.Rulesets.Osu/Statistics/AimError.cs @@ -0,0 +1,67 @@ +// 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 osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Statistics +{ + /// + /// Displays the unstable rate statistic for a given play. + /// + public partial class AimError : SimpleStatisticItem + { + private readonly List hitPoints = new List(); + + /// + /// Creates and computes an statistic. + /// + public AimError(IEnumerable hitEvents) + : base("Aim Error") + { + Value = calculateAimError(hitEvents); + } + + private double? calculateAimError(IEnumerable hitEvents) + { + IEnumerable rawHitPositions = hitEvents.Where(affectsAimError); + + if (!rawHitPositions.Any()) + return null; + + foreach (var e in hitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle))) + { + if (e.LastHitObject == null || e.Position == null) + continue; + + addAngleAdjustedPoint(((OsuHitObject)e.LastHitObject).StackedEndPosition, ((OsuHitObject)e.HitObject).StackedEndPosition, e.Position.Value); + } + + Vector2 averagePosition = new Vector2(hitPoints.Sum(x => x[0]), hitPoints.Sum(x => x[1])) / hitEvents.Where(affectsAimError).Count(); + + return Math.Sqrt(hitPoints.Average(x => (x - averagePosition).LengthSquared)) * 10; + } + + private void addAngleAdjustedPoint(Vector2 start, Vector2 end, Vector2 hitPoint) + { + double angle1 = Math.Atan2(end.Y - hitPoint.Y, hitPoint.X - end.X); // Angle between the end point and the hit point. + double angle2 = Math.Atan2(end.Y - start.Y, start.X - end.X); // Angle between the end point and the start point. + double finalAngle = angle2 - angle1; // Angle between start, end, and hit points. + + double distanceFromCenter = (hitPoint - end).Length; + + Vector2 angleAdjustedPoint = new Vector2((float)(Math.Sin(finalAngle) * distanceFromCenter), (float)(Math.Cos(finalAngle) * distanceFromCenter)); + + hitPoints.Add(angleAdjustedPoint); + } + + private bool affectsAimError(HitEvent hitEvent) => hitEvent.HitObject is HitCircle && !(hitEvent.HitObject is SliderTailCircle) && hitEvent.Result.IsHit(); + + protected override string DisplayValue(double? value) => value == null ? "(not available)" : value.Value.ToString(@"N2"); + } +} diff --git a/osu.Game.Rulesets.Osu/Statistics/AverageAimError.cs b/osu.Game.Rulesets.Osu/Statistics/AverageAimError.cs new file mode 100644 index 0000000000..e149c5f3b1 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Statistics/AverageAimError.cs @@ -0,0 +1,68 @@ +// 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 osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Statistics +{ + /// + /// Displays the unstable rate statistic for a given play. + /// + public partial class AverageAimError : SimpleStatisticItem + { + private readonly List hitPoints = new List(); + + /// + /// Creates and computes an statistic. + /// + /// Sequence of s to calculate the unstable rate based on. + public AverageAimError(IEnumerable hitEvents) + : base("Average Hit Error") + { + Value = calculateAverageAimError(hitEvents); + } + + private double? calculateAverageAimError(IEnumerable hitEvents) + { + IEnumerable rawHitPositions = hitEvents.Where(affectsAimError); + + if (!rawHitPositions.Any()) + return null; + + foreach (var e in rawHitPositions) + { + if (e.LastHitObject == null || e.Position == null) + continue; + + addAngleAdjustedPoint(((OsuHitObject)e.LastHitObject).StackedEndPosition, ((OsuHitObject)e.HitObject).StackedEndPosition, e.Position.Value); + } + + Vector2 averagePosition = new Vector2(hitPoints.Sum(x => x[0]), hitPoints.Sum(x => x[1])) / rawHitPositions.Count(); + + return averagePosition.Length; + } + + private void addAngleAdjustedPoint(Vector2 start, Vector2 end, Vector2 hitPoint) + { + double angle1 = Math.Atan2(end.Y - hitPoint.Y, hitPoint.X - end.X); // Angle between the end point and the hit point. + double angle2 = Math.Atan2(end.Y - start.Y, start.X - end.X); // Angle between the end point and the start point. + double finalAngle = angle2 - angle1; // Angle between start, end, and hit points. + + double distanceFromCenter = (hitPoint - end).Length; + + Vector2 angleAdjustedPoint = new Vector2((float)(Math.Sin(finalAngle) * distanceFromCenter), (float)(Math.Cos(finalAngle) * distanceFromCenter)); + + hitPoints.Add(angleAdjustedPoint); + } + + private bool affectsAimError(HitEvent hitEvent) => hitEvent.HitObject is HitCircle && !(hitEvent.HitObject is SliderTailCircle) && hitEvent.Result.IsHit(); + + protected override string DisplayValue(double? value) => value == null ? "(not available)" : $"{Math.Abs(value.Value):N2} osu!px from center"; + } +}