Merge pull request #20143 from acid-chicken/feat/stats/colored-td

Show judgement colours in hit distribution graph
This commit is contained in:
Dean Herbert 2022-09-08 19:15:40 +09:00 committed by GitHub
commit 096d1c3ff3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 164 additions and 69 deletions

View File

@ -1,8 +1,6 @@
// 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.
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
@ -20,7 +18,7 @@ namespace osu.Game.Tests.Visual.Ranking
{
public class TestSceneHitEventTimingDistributionGraph : OsuTestScene
{
private HitEventTimingDistributionGraph graph;
private HitEventTimingDistributionGraph graph = null!;
private static readonly HitObject placeholder_object = new HitCircle();
@ -43,6 +41,65 @@ namespace osu.Game.Tests.Visual.Ranking
createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, placeholder_object, placeholder_object, null)).ToList());
}
[Test]
public void TestSparse()
{
createTest(new List<HitEvent>
{
new HitEvent(-7, HitResult.Perfect, placeholder_object, placeholder_object, null),
new HitEvent(-6, HitResult.Perfect, placeholder_object, placeholder_object, null),
new HitEvent(-5, HitResult.Perfect, placeholder_object, placeholder_object, null),
new HitEvent(5, HitResult.Perfect, placeholder_object, placeholder_object, null),
new HitEvent(6, HitResult.Perfect, placeholder_object, placeholder_object, null),
new HitEvent(7, HitResult.Perfect, placeholder_object, placeholder_object, null),
});
}
[Test]
public void TestVariousTypesOfHitResult()
{
createTest(CreateDistributedHitEvents(0, 50).Select(h =>
{
double offset = Math.Abs(h.TimeOffset);
HitResult result = offset > 36 ? HitResult.Miss
: offset > 32 ? HitResult.Meh
: offset > 24 ? HitResult.Ok
: offset > 16 ? HitResult.Good
: offset > 8 ? HitResult.Great
: HitResult.Perfect;
return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null);
}).ToList());
}
[Test]
public void TestMultipleWindowsOfHitResult()
{
var wide = CreateDistributedHitEvents(0, 50).Select(h =>
{
double offset = Math.Abs(h.TimeOffset);
HitResult result = offset > 36 ? HitResult.Miss
: offset > 32 ? HitResult.Meh
: offset > 24 ? HitResult.Ok
: offset > 16 ? HitResult.Good
: offset > 8 ? HitResult.Great
: HitResult.Perfect;
return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null);
});
var narrow = CreateDistributedHitEvents(0, 50).Select(h =>
{
double offset = Math.Abs(h.TimeOffset);
HitResult result = offset > 25 ? HitResult.Miss
: offset > 20 ? HitResult.Meh
: offset > 15 ? HitResult.Ok
: offset > 10 ? HitResult.Good
: offset > 5 ? HitResult.Great
: HitResult.Perfect;
return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null);
});
createTest(wide.Concat(narrow).ToList());
}
[Test]
public void TestZeroTimeOffset()
{

View File

@ -102,26 +102,31 @@ namespace osu.Game.Graphics
/// <summary>
/// Retrieves the colour for a <see cref="HitResult"/>.
/// </summary>
public Color4 ForHitResult(HitResult judgement)
public Color4 ForHitResult(HitResult result)
{
switch (judgement)
switch (result)
{
case HitResult.Perfect:
case HitResult.Great:
return Blue;
case HitResult.Ok:
case HitResult.Good:
return Green;
case HitResult.SmallTickMiss:
case HitResult.LargeTickMiss:
case HitResult.Miss:
return Red;
case HitResult.Meh:
return Yellow;
case HitResult.Miss:
return Red;
case HitResult.Ok:
return Green;
case HitResult.Good:
return GreenLight;
case HitResult.SmallTickHit:
case HitResult.LargeTickHit:
case HitResult.Great:
return Blue;
default:
return Color4.White;
return BlueLight;
}
}

View File

@ -1,13 +1,13 @@
// 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.
#nullable disable
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Runtime.Serialization;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Utils;
namespace osu.Game.Rulesets.Scoring
@ -135,6 +135,8 @@ namespace osu.Game.Rulesets.Scoring
#pragma warning disable CS0618
public static class HitResultExtensions
{
private static readonly IList<HitResult> order = EnumExtensions.GetValuesInOrder<HitResult>().ToList();
/// <summary>
/// Whether a <see cref="HitResult"/> increases the combo.
/// </summary>
@ -282,6 +284,13 @@ namespace osu.Game.Rulesets.Scoring
Debug.Assert(minResult <= maxResult);
return result > minResult && result < maxResult;
}
/// <summary>
/// Ordered index of a <see cref="HitResult"/>. Used for consistent order when displaying hit results to the user.
/// </summary>
/// <param name="result">The <see cref="HitResult"/> to get the index of.</param>
/// <returns>The index of <paramref name="result"/>.</returns>
public static int GetIndexForOrderedDisplay(this HitResult result) => order.IndexOf(result);
}
#pragma warning restore CS0618
}

View File

@ -59,30 +59,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
protected Color4 GetColourForHitResult(HitResult result)
{
switch (result)
{
case HitResult.SmallTickMiss:
case HitResult.LargeTickMiss:
case HitResult.Miss:
return colours.Red;
case HitResult.Meh:
return colours.Yellow;
case HitResult.Ok:
return colours.Green;
case HitResult.Good:
return colours.GreenLight;
case HitResult.SmallTickHit:
case HitResult.LargeTickHit:
case HitResult.Great:
return colours.Blue;
default:
return colours.BlueLight;
}
return colours.ForHitResult(result);
}
/// <summary>

View File

@ -7,7 +7,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@ -57,7 +56,7 @@ namespace osu.Game.Screens.Ranking.Statistics
this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit()).ToList();
}
private int[] bins;
private IDictionary<HitResult, int>[] bins;
private double binSize;
private double hitOffset;
@ -69,7 +68,7 @@ namespace osu.Game.Screens.Ranking.Statistics
if (hitEvents == null || hitEvents.Count == 0)
return;
bins = new int[total_timing_distribution_bins];
bins = Enumerable.Range(0, total_timing_distribution_bins).Select(_ => new Dictionary<HitResult, int>()).ToArray<IDictionary<HitResult, int>>();
binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins);
@ -89,7 +88,8 @@ namespace osu.Game.Screens.Ranking.Statistics
{
bool roundUp = true;
Array.Clear(bins, 0, bins.Length);
foreach (var bin in bins)
bin.Clear();
foreach (var e in hitEvents)
{
@ -110,23 +110,23 @@ namespace osu.Game.Screens.Ranking.Statistics
// may be out of range when applying an offset. for such cases we can just drop the results.
if (index >= 0 && index < bins.Length)
bins[index]++;
{
bins[index].TryGetValue(e.Result, out int value);
bins[index][e.Result] = ++value;
}
}
if (barDrawables != null)
{
for (int i = 0; i < barDrawables.Length; i++)
{
barDrawables[i].UpdateOffset(bins[i]);
barDrawables[i].UpdateOffset(bins[i].Sum(b => b.Value));
}
}
else
{
int maxCount = bins.Max();
barDrawables = new Bar[total_timing_distribution_bins];
for (int i = 0; i < barDrawables.Length; i++)
barDrawables[i] = new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index);
int maxCount = bins.Max(b => b.Values.Sum());
barDrawables = bins.Select((bin, i) => new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index)).ToArray();
Container axisFlow;
@ -209,50 +209,97 @@ namespace osu.Game.Screens.Ranking.Statistics
private class Bar : CompositeDrawable
{
private readonly float value;
private readonly float maxValue;
private float totalValue => values.Sum(v => v.Value);
private float basalHeight => BoundingBox.Width / BoundingBox.Height;
private float availableHeight => 1 - basalHeight;
private readonly Circle boxOriginal;
private readonly IReadOnlyList<KeyValuePair<HitResult, int>> values;
private readonly float maxValue;
private readonly bool isCentre;
private Circle[] boxOriginals;
private Circle boxAdjustment;
private const float minimum_height = 0.05f;
[Resolved]
private OsuColour colours { get; set; }
public Bar(float value, float maxValue, bool isCentre)
public Bar(IDictionary<HitResult, int> values, float maxValue, bool isCentre)
{
this.value = value;
this.values = values.OrderBy(v => v.Key.GetIndexForOrderedDisplay()).ToList();
this.maxValue = maxValue;
this.isCentre = isCentre;
RelativeSizeAxes = Axes.Both;
Masking = true;
}
InternalChildren = new Drawable[]
[BackgroundDependencyLoader]
private void load()
{
if (values.Any())
{
boxOriginal = new Circle
boxOriginals = values.Select((v, i) => new Circle
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Colour = isCentre ? Color4.White : Color4Extensions.FromHex("#66FFCC"),
Height = minimum_height,
},
};
Colour = isCentre && i == 0 ? Color4.White : colours.ForHitResult(v.Key),
Height = 0,
}).ToArray();
// The bars of the stacked bar graph will be processed (stacked) from the bottom, which is the base position,
// to the top, and the bottom bar should be drawn more toward the front by design,
// while the drawing order is from the back to the front, so the order passed to `InternalChildren` is the opposite.
InternalChildren = boxOriginals.Reverse().ToArray();
}
else
{
// A bin with no value draws a grey dot instead.
InternalChildren = boxOriginals = new[]
{
new Circle
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Colour = isCentre ? Color4.White : Color4.Gray,
Height = 0,
},
};
}
}
private const double duration = 300;
private float offsetForValue(float value)
{
return availableHeight * value / maxValue;
}
private float heightForValue(float value)
{
return basalHeight + offsetForValue(value);
}
protected override void LoadComplete()
{
base.LoadComplete();
float height = Math.Clamp(value / maxValue, minimum_height, 1);
foreach (var boxOriginal in boxOriginals)
boxOriginal.Height = basalHeight;
if (height > minimum_height)
boxOriginal.ResizeHeightTo(height, duration, Easing.OutQuint);
float offsetValue = 0;
for (int i = 0; i < values.Count; i++)
{
boxOriginals[i].MoveToY(offsetForValue(offsetValue) * BoundingBox.Height, duration, Easing.OutQuint);
boxOriginals[i].ResizeHeightTo(heightForValue(values[i].Value), duration, Easing.OutQuint);
offsetValue -= values[i].Value;
}
}
public void UpdateOffset(float adjustment)
{
bool hasAdjustment = adjustment != value && adjustment / maxValue >= minimum_height;
bool hasAdjustment = adjustment != totalValue;
if (boxAdjustment == null)
{
@ -271,7 +318,7 @@ namespace osu.Game.Screens.Ranking.Statistics
});
}
boxAdjustment.ResizeHeightTo(Math.Clamp(adjustment / maxValue, minimum_height, 1), duration, Easing.OutQuint);
boxAdjustment.ResizeHeightTo(heightForValue(adjustment), duration, Easing.OutQuint);
boxAdjustment.FadeTo(!hasAdjustment ? 0 : 1, duration, Easing.OutQuint);
}
}