1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-28 17:32:54 +08:00

Merge pull request #17093 from peppy/offset-ui-improvements

Show beatmap offset adjustments in a more visual way
This commit is contained in:
Dean Herbert 2022-03-06 02:17:36 +09:00 committed by GitHub
commit 4de66bb1c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 205 additions and 90 deletions

View File

@ -62,11 +62,11 @@ namespace osu.Game.Tests.Visual.Gameplay
};
});
AddAssert("Has calibration button", () => offsetControl.ChildrenOfType<SettingsButton>().Any());
AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType<SettingsButton>().Any());
AddStep("Press button", () => offsetControl.ChildrenOfType<SettingsButton>().Single().TriggerClick());
AddAssert("Offset is adjusted", () => offsetControl.Current.Value == -average_error);
AddAssert("Button is disabled", () => !offsetControl.ChildrenOfType<SettingsButton>().Single().Enabled.Value);
AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType<SettingsButton>().Single().Enabled.Value);
AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null);
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
}
@ -90,11 +90,11 @@ namespace osu.Game.Tests.Visual.Gameplay
};
});
AddAssert("Has calibration button", () => offsetControl.ChildrenOfType<SettingsButton>().Any());
AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType<SettingsButton>().Any());
AddStep("Press button", () => offsetControl.ChildrenOfType<SettingsButton>().Single().TriggerClick());
AddAssert("Offset is adjusted", () => offsetControl.Current.Value == initial_offset - average_error);
AddAssert("Button is disabled", () => !offsetControl.ChildrenOfType<SettingsButton>().Single().Enabled.Value);
AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType<SettingsButton>().Single().Enabled.Value);
AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null);
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
}

View File

@ -8,6 +8,7 @@ using NUnit.Framework;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Ranking.Statistics;
@ -17,10 +18,15 @@ namespace osu.Game.Tests.Visual.Ranking
{
public class TestSceneHitEventTimingDistributionGraph : OsuTestScene
{
private HitEventTimingDistributionGraph graph;
private static readonly HitObject placeholder_object = new HitCircle();
[Test]
public void TestManyDistributedEvents()
{
createTest(CreateDistributedHitEvents());
AddStep("add adjustment", () => graph.UpdateOffset(10));
}
[Test]
@ -32,13 +38,13 @@ namespace osu.Game.Tests.Visual.Ranking
[Test]
public void TestAroundCentre()
{
createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList());
createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, placeholder_object, placeholder_object, null)).ToList());
}
[Test]
public void TestZeroTimeOffset()
{
createTest(Enumerable.Range(0, 100).Select(_ => new HitEvent(0, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList());
createTest(Enumerable.Range(0, 100).Select(_ => new HitEvent(0, HitResult.Perfect, placeholder_object, placeholder_object, null)).ToList());
}
[Test]
@ -53,9 +59,9 @@ namespace osu.Game.Tests.Visual.Ranking
createTest(Enumerable.Range(0, 100).Select(i =>
{
if (i % 2 == 0)
return new HitEvent(0, HitResult.Perfect, new HitCircle(), new HitCircle(), null);
return new HitEvent(0, HitResult.Perfect, placeholder_object, placeholder_object, null);
return new HitEvent(30, HitResult.Miss, new HitCircle(), new HitCircle(), null);
return new HitEvent(30, HitResult.Miss, placeholder_object, placeholder_object, null);
}).ToList());
}
@ -68,7 +74,7 @@ namespace osu.Game.Tests.Visual.Ranking
RelativeSizeAxes = Axes.Both,
Colour = Color4Extensions.FromHex("#333")
},
new HitEventTimingDistributionGraph(events)
graph = new HitEventTimingDistributionGraph(events)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@ -83,10 +89,10 @@ namespace osu.Game.Tests.Visual.Ranking
for (int i = 0; i < range * 2; i++)
{
int count = (int)(Math.Pow(range - Math.Abs(i - range), 2));
int count = (int)(Math.Pow(range - Math.Abs(i - range), 2)) / 10;
for (int j = 0; j < count; j++)
hitEvents.Add(new HitEvent(centre + i - range, HitResult.Perfect, new HitCircle(), new HitCircle(), null));
hitEvents.Add(new HitEvent(centre + i - range, HitResult.Perfect, placeholder_object, placeholder_object, null));
}
return hitEvents;

View File

@ -270,7 +270,13 @@ namespace osu.Game.Configuration
MouseDisableButtons,
MouseDisableWheel,
ConfineMouseMode,
/// <summary>
/// Globally applied audio offset.
/// This is added to the audio track's current time. Higher values will cause gameplay to occur earlier, relative to the audio track.
/// </summary>
AudioOffset,
VolumeInactive,
MenuMusic,
MenuVoice,

View File

@ -54,6 +54,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
private double lastPlayAverage;
private double lastPlayBeatmapOffset;
private HitEventTimingDistributionGraph? lastPlayGraph;
private SettingsButton? useAverageButton;
@ -107,8 +108,8 @@ namespace osu.Game.Screens.Play.PlayerSettings
Debug.Assert(value != 0);
return value > 0
? BeatmapOffsetControlStrings.HitObjectsAppearLater
: BeatmapOffsetControlStrings.HitObjectsAppearEarlier;
? BeatmapOffsetControlStrings.HitObjectsAppearEarlier
: BeatmapOffsetControlStrings.HitObjectsAppearLater;
}
}
}
@ -147,6 +148,12 @@ namespace osu.Game.Screens.Play.PlayerSettings
void updateOffset()
{
// the last play graph is relative to the offset at the point of the last play, so we need to factor that out.
double adjustmentSinceLastPlay = lastPlayBeatmapOffset - Current.Value;
// Negative is applied here because the play graph is considering a hit offset, not track (as we currently use for clocks).
lastPlayGraph?.UpdateOffset(-adjustmentSinceLastPlay);
// ensure the previous write has completed. ignoring performance concerns, if we don't do this, the async writes could be out of sequence.
if (realmWriteTask?.IsCompleted == false)
{
@ -155,7 +162,9 @@ namespace osu.Game.Screens.Play.PlayerSettings
}
if (useAverageButton != null)
useAverageButton.Enabled.Value = !Precision.AlmostEquals(Current.Value, lastPlayBeatmapOffset - lastPlayAverage, Current.Precision / 2);
{
useAverageButton.Enabled.Value = !Precision.AlmostEquals(lastPlayAverage, adjustmentSinceLastPlay, Current.Precision / 2);
}
realmWriteTask = realm.WriteAsync(r =>
{
@ -216,7 +225,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
referenceScoreContainer.AddRange(new Drawable[]
{
new HitEventTimingDistributionGraph(hitEvents)
lastPlayGraph = new HitEventTimingDistributionGraph(hitEvents)
{
RelativeSizeAxes = Axes.X,
Height = 50,

View File

@ -12,6 +12,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Scoring;
using osuTK.Graphics;
namespace osu.Game.Screens.Ranking.Statistics
{
@ -40,6 +41,9 @@ namespace osu.Game.Screens.Ranking.Statistics
/// </summary>
private const float axis_points = 5;
/// <summary>
/// The currently displayed hit events.
/// </summary>
private readonly IReadOnlyList<HitEvent> hitEvents;
/// <summary>
@ -51,24 +55,45 @@ 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 double binSize;
private double hitOffset;
private Bar[] barDrawables;
[BackgroundDependencyLoader]
private void load()
{
if (hitEvents == null || hitEvents.Count == 0)
return;
int[] bins = new int[total_timing_distribution_bins];
bins = new int[total_timing_distribution_bins];
double binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins);
binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins);
// Prevent div-by-0 by enforcing a minimum bin size
binSize = Math.Max(1, binSize);
Scheduler.AddOnce(updateDisplay);
}
public void UpdateOffset(double hitOffset)
{
this.hitOffset = hitOffset;
Scheduler.AddOnce(updateDisplay);
}
private void updateDisplay()
{
bool roundUp = true;
Array.Clear(bins, 0, bins.Length);
foreach (var e in hitEvents)
{
double binOffset = e.TimeOffset / binSize;
double time = e.TimeOffset + hitOffset;
double binOffset = time / binSize;
// .NET's round midpoint handling doesn't provide a behaviour that works amazingly for display
// purposes here. We want midpoint rounding to roughly distribute evenly to each adjacent bucket
@ -79,105 +104,174 @@ namespace osu.Game.Screens.Ranking.Statistics
roundUp = !roundUp;
}
bins[timing_distribution_centre_bin_index + (int)Math.Round(binOffset, MidpointRounding.AwayFromZero)]++;
int index = timing_distribution_centre_bin_index + (int)Math.Round(binOffset, MidpointRounding.AwayFromZero);
// 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]++;
}
int maxCount = bins.Max();
var bars = new Drawable[total_timing_distribution_bins];
for (int i = 0; i < bars.Length; i++)
bars[i] = new Bar { Height = Math.Max(0.05f, (float)bins[i] / maxCount) };
Container axisFlow;
InternalChild = new GridContainer
if (barDrawables != null)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Width = 0.8f,
Content = new[]
for (int i = 0; i < barDrawables.Length; i++)
{
new Drawable[]
{
new GridContainer
{
RelativeSizeAxes = Axes.Both,
Content = new[] { bars }
}
},
new Drawable[]
{
axisFlow = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
}
},
},
RowDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize),
barDrawables[i].UpdateOffset(bins[i]);
}
};
// Our axis will contain one centre element + 5 points on each side, each with a value depending on the number of bins * bin size.
double maxValue = timing_distribution_bins * binSize;
double axisValueStep = maxValue / axis_points;
axisFlow.Add(new OsuSpriteText
}
else
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "0",
Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)
});
int maxCount = bins.Max();
barDrawables = new Bar[total_timing_distribution_bins];
for (int i = 1; i <= axis_points; i++)
{
double axisValue = i * axisValueStep;
float position = (float)(axisValue / maxValue);
float alpha = 1f - position * 0.8f;
for (int i = 0; i < barDrawables.Length; i++)
barDrawables[i] = new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index);
Container axisFlow;
const float axis_font_size = 12;
InternalChild = new GridContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Width = 0.8f,
Content = new[]
{
new Drawable[]
{
new GridContainer
{
RelativeSizeAxes = Axes.Both,
Content = new[] { barDrawables }
}
},
new Drawable[]
{
axisFlow = new Container
{
RelativeSizeAxes = Axes.X,
Height = axis_font_size,
}
},
},
RowDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize),
}
};
// Our axis will contain one centre element + 5 points on each side, each with a value depending on the number of bins * bin size.
double maxValue = timing_distribution_bins * binSize;
double axisValueStep = maxValue / axis_points;
axisFlow.Add(new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativePositionAxes = Axes.X,
X = -position / 2,
Alpha = alpha,
Text = axisValue.ToString("-0"),
Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)
Text = "0",
Font = OsuFont.GetFont(size: axis_font_size, weight: FontWeight.SemiBold)
});
axisFlow.Add(new OsuSpriteText
for (int i = 1; i <= axis_points; i++)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativePositionAxes = Axes.X,
X = position / 2,
Alpha = alpha,
Text = axisValue.ToString("+0"),
Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)
});
double axisValue = i * axisValueStep;
float position = (float)(axisValue / maxValue);
float alpha = 1f - position * 0.8f;
axisFlow.Add(new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativePositionAxes = Axes.X,
X = -position / 2,
Alpha = alpha,
Text = axisValue.ToString("-0"),
Font = OsuFont.GetFont(size: axis_font_size, weight: FontWeight.SemiBold)
});
axisFlow.Add(new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativePositionAxes = Axes.X,
X = position / 2,
Alpha = alpha,
Text = axisValue.ToString("+0"),
Font = OsuFont.GetFont(size: axis_font_size, weight: FontWeight.SemiBold)
});
}
}
}
private class Bar : CompositeDrawable
{
public Bar()
private readonly float value;
private readonly float maxValue;
private readonly Circle boxOriginal;
private Circle boxAdjustment;
private const float minimum_height = 0.05f;
public Bar(float value, float maxValue, bool isCentre)
{
Anchor = Anchor.BottomCentre;
Origin = Anchor.BottomCentre;
this.value = value;
this.maxValue = maxValue;
RelativeSizeAxes = Axes.Both;
Masking = true;
InternalChild = new Circle
InternalChildren = new Drawable[]
{
RelativeSizeAxes = Axes.Both,
Colour = Color4Extensions.FromHex("#66FFCC")
boxOriginal = new Circle
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Colour = isCentre ? Color4.White : Color4Extensions.FromHex("#66FFCC"),
Height = minimum_height,
},
};
}
private const double duration = 300;
protected override void LoadComplete()
{
base.LoadComplete();
float height = Math.Clamp(value / maxValue, minimum_height, 1);
if (height > minimum_height)
boxOriginal.ResizeHeightTo(height, duration, Easing.OutQuint);
}
public void UpdateOffset(float adjustment)
{
bool hasAdjustment = adjustment != value && adjustment / maxValue >= minimum_height;
if (boxAdjustment == null)
{
if (!hasAdjustment)
return;
AddInternal(boxAdjustment = new Circle
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Colour = Color4.Yellow,
Blending = BlendingParameters.Additive,
Alpha = 0.6f,
Height = 0,
});
}
boxAdjustment.ResizeHeightTo(Math.Clamp(adjustment / maxValue, minimum_height, 1), duration, Easing.OutQuint);
boxAdjustment.FadeTo(!hasAdjustment ? 0 : 1, duration, Easing.OutQuint);
}
}
}
}