Refactor hit windows class structure to reduce rigidity

This change pulls back a significant degree of overspecialisation and
rigidity in the class structure of `HitWindows` to make subsequent
changes to hit windows, whose purpose is to improve replay playback
accuracy, possible to do cleanly.

Notably:

- `HitWindows` is full abstract now. In a few use cases, and as a
  reference for ruleset implementors, `DefaultHitWindows` is provided as
  a separate class instead.

  This fixes the weirdness wherein `HitWindows` always declared 6 fields
  for result types but some of them would never be set to a non-zero
  value or read.

- `HitWindow.GetRanges()` is deleted because it is overspecialised and
  prevents being able to adjust hitwindows by ±0.5ms cleanly which will
  be required later.

  The fallout of this is that the assertion that used `GetRanges()` in
  the `HitWindows` ctor must use something else now, and the closest
  thing to it was `GetAllAvailableWindows()`, which didn't return
  the miss window - so I made it return the miss window and fixed the
  one consumer that didn't want it (bar hit error meter) to skip it.

- Diff also contains some clean-up around `DifficultyRange` to unify
  handling of it.
This commit is contained in:
Bartłomiej Dach
2025-05-09 10:43:44 +02:00
parent c5ce191ceb
commit 8b542e5442
18 changed files with 254 additions and 170 deletions

View File

@@ -1,15 +1,30 @@
// 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.
using System.Linq;
using System;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Scoring
{
public class ManiaHitWindows : HitWindows
{
private static readonly DifficultyRange perfect_window_range = new DifficultyRange(22.4D, 19.4D, 13.9D);
private static readonly DifficultyRange great_window_range = new DifficultyRange(64, 49, 34);
private static readonly DifficultyRange good_window_range = new DifficultyRange(97, 82, 67);
private static readonly DifficultyRange ok_window_range = new DifficultyRange(127, 112, 97);
private static readonly DifficultyRange meh_window_range = new DifficultyRange(151, 136, 121);
private static readonly DifficultyRange miss_window_range = new DifficultyRange(188, 173, 158);
private readonly double multiplier;
private double perfect;
private double great;
private double good;
private double ok;
private double meh;
private double miss;
public ManiaHitWindows()
: this(1)
{
@@ -36,11 +51,41 @@ namespace osu.Game.Rulesets.Mania.Scoring
return false;
}
protected override DifficultyRange[] GetRanges() => base.GetRanges().Select(r =>
new DifficultyRange(
r.Result,
r.Min * multiplier,
r.Average * multiplier,
r.Max * multiplier)).ToArray();
public override void SetDifficulty(double difficulty)
{
perfect = IBeatmapDifficultyInfo.DifficultyRange(difficulty, perfect_window_range) * multiplier;
great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, great_window_range) * multiplier;
good = IBeatmapDifficultyInfo.DifficultyRange(difficulty, good_window_range) * multiplier;
ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, ok_window_range) * multiplier;
meh = IBeatmapDifficultyInfo.DifficultyRange(difficulty, meh_window_range) * multiplier;
miss = IBeatmapDifficultyInfo.DifficultyRange(difficulty, miss_window_range) * multiplier;
}
public override double WindowFor(HitResult result)
{
switch (result)
{
case HitResult.Perfect:
return perfect;
case HitResult.Great:
return great;
case HitResult.Good:
return good;
case HitResult.Ok:
return ok;
case HitResult.Meh:
return meh;
case HitResult.Miss:
return miss;
default:
throw new ArgumentOutOfRangeException(nameof(result), result, null);
}
}
}
}

View File

@@ -476,15 +476,24 @@ namespace osu.Game.Rulesets.Osu.Tests
private class TestHitWindows : HitWindows
{
private static readonly DifficultyRange[] ranges =
{
new DifficultyRange(HitResult.Great, 500, 500, 500),
new DifficultyRange(HitResult.Miss, early_miss_window, early_miss_window, early_miss_window),
};
public override bool IsHitResultAllowed(HitResult result) => result == HitResult.Great || result == HitResult.Miss;
protected override DifficultyRange[] GetRanges() => ranges;
public override void SetDifficulty(double difficulty) { }
public override double WindowFor(HitResult result)
{
switch (result)
{
case HitResult.Great:
return 500;
case HitResult.Miss:
return early_miss_window;
default:
throw new ArgumentOutOfRangeException(nameof(result), result, null);
}
}
}
private partial class ScoreAccessibleReplayPlayer : ReplayPlayer

View File

@@ -21,12 +21,12 @@ namespace osu.Game.Rulesets.Osu.Objects
/// <summary>
/// The RPM required to clear the spinner at ODs [ 0, 5, 10 ].
/// </summary>
private static readonly (int min, int mid, int max) clear_rpm_range = (90, 150, 225);
private static readonly DifficultyRange clear_rpm_range = new DifficultyRange(90, 150, 225);
/// <summary>
/// The RPM required to complete the spinner and receive full score at ODs [ 0, 5, 10 ].
/// </summary>
private static readonly (int min, int mid, int max) complete_rpm_range = (250, 380, 430);
private static readonly DifficultyRange complete_rpm_range = new DifficultyRange(250, 380, 430);
public double EndTime
{

View File

@@ -373,10 +373,9 @@ namespace osu.Game.Rulesets.Osu
preempt /= rate;
adjustedDifficulty.ApproachRate = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN);
var greatHitWindowRange = OsuHitWindows.OSU_RANGES.Single(range => range.Result == HitResult.Great);
double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max);
double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, OsuHitWindows.GREAT_WINDOW_RANGE);
greatHitWindow /= rate;
adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max);
adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, OsuHitWindows.GREAT_WINDOW_RANGE);
return adjustedDifficulty;
}

View File

@@ -1,24 +1,26 @@
// 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.
using System;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Scoring
{
public class OsuHitWindows : HitWindows
{
public static readonly DifficultyRange GREAT_WINDOW_RANGE = new DifficultyRange(80, 50, 20);
public static readonly DifficultyRange OK_WINDOW_RANGE = new DifficultyRange(140, 100, 60);
public static readonly DifficultyRange MEH_WINDOW_RANGE = new DifficultyRange(200, 150, 100);
/// <summary>
/// osu! ruleset has a fixed miss window regardless of difficulty settings.
/// </summary>
public const double MISS_WINDOW = 400;
internal static readonly DifficultyRange[] OSU_RANGES =
{
new DifficultyRange(HitResult.Great, 80, 50, 20),
new DifficultyRange(HitResult.Ok, 140, 100, 60),
new DifficultyRange(HitResult.Meh, 200, 150, 100),
new DifficultyRange(HitResult.Miss, MISS_WINDOW, MISS_WINDOW, MISS_WINDOW),
};
private double great;
private double ok;
private double meh;
public override bool IsHitResultAllowed(HitResult result)
{
@@ -34,6 +36,32 @@ namespace osu.Game.Rulesets.Osu.Scoring
return false;
}
protected override DifficultyRange[] GetRanges() => OSU_RANGES;
public override void SetDifficulty(double difficulty)
{
great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, GREAT_WINDOW_RANGE);
ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, OK_WINDOW_RANGE);
meh = IBeatmapDifficultyInfo.DifficultyRange(difficulty, MEH_WINDOW_RANGE);
}
public override double WindowFor(HitResult result)
{
switch (result)
{
case HitResult.Great:
return great;
case HitResult.Ok:
return ok;
case HitResult.Meh:
return meh;
case HitResult.Miss:
return MISS_WINDOW;
default:
throw new ArgumentOutOfRangeException(nameof(result), result, null);
}
}
}
}

View File

@@ -171,7 +171,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 6 });
beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 10 });
var hitWindows = new HitWindows();
var hitWindows = new DefaultHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
PerformTest(new List<ReplayFrame>

View File

@@ -1,18 +1,21 @@
// 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.
using System;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Taiko.Scoring
{
public class TaikoHitWindows : HitWindows
{
internal static readonly DifficultyRange[] TAIKO_RANGES =
{
new DifficultyRange(HitResult.Great, 50, 35, 20),
new DifficultyRange(HitResult.Ok, 120, 80, 50),
new DifficultyRange(HitResult.Miss, 135, 95, 70),
};
public static readonly DifficultyRange GREAT_WINDOW_RANGE = new DifficultyRange(50, 35, 20);
public static readonly DifficultyRange OK_WINDOW_RANGE = new DifficultyRange(120, 80, 50);
public static readonly DifficultyRange MISS_WINDOW_RANGE = new DifficultyRange(135, 95, 70);
private double great;
private double ok;
private double miss;
public override bool IsHitResultAllowed(HitResult result)
{
@@ -27,6 +30,29 @@ namespace osu.Game.Rulesets.Taiko.Scoring
return false;
}
protected override DifficultyRange[] GetRanges() => TAIKO_RANGES;
public override void SetDifficulty(double difficulty)
{
great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, GREAT_WINDOW_RANGE);
ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, OK_WINDOW_RANGE);
miss = IBeatmapDifficultyInfo.DifficultyRange(difficulty, MISS_WINDOW_RANGE);
}
public override double WindowFor(HitResult result)
{
switch (result)
{
case HitResult.Great:
return great;
case HitResult.Ok:
return ok;
case HitResult.Miss:
return miss;
default:
throw new ArgumentOutOfRangeException(nameof(result), result, null);
}
}
}
}

View File

@@ -274,10 +274,9 @@ namespace osu.Game.Rulesets.Taiko
{
BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(difficulty);
var greatHitWindowRange = TaikoHitWindows.TAIKO_RANGES.Single(range => range.Result == HitResult.Great);
double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max);
double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, TaikoHitWindows.GREAT_WINDOW_RANGE);
greatHitWindow /= rate;
adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max);
adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, TaikoHitWindows.GREAT_WINDOW_RANGE);
return adjustedDifficulty;
}

View File

@@ -359,7 +359,7 @@ namespace osu.Game.Tests.Gameplay
}
public override Judgement CreateJudgement() => new TestJudgement(maxResult);
protected override HitWindows CreateHitWindows() => new HitWindows();
protected override HitWindows CreateHitWindows() => new DefaultHitWindows();
private class TestJudgement : Judgement
{

View File

@@ -33,7 +33,7 @@ namespace osu.Game.Tests.NonVisual
public void TestResultIfOnlyParentHitWindowIsEmpty()
{
var testObject = new TestHitObject(HitWindows.Empty);
HitObject nested = new TestHitObject(new HitWindows());
HitObject nested = new TestHitObject(new DefaultHitWindows());
testObject.AddNested(nested);
testDrawableRuleset.HitObjects = new List<HitObject> { testObject };
@@ -43,8 +43,8 @@ namespace osu.Game.Tests.NonVisual
[Test]
public void TestResultIfParentHitWindowsIsNotEmpty()
{
var testObject = new TestHitObject(new HitWindows());
HitObject nested = new TestHitObject(new HitWindows());
var testObject = new TestHitObject(new DefaultHitWindows());
HitObject nested = new TestHitObject(new DefaultHitWindows());
testObject.AddNested(nested);
testDrawableRuleset.HitObjects = new List<HitObject> { testObject };
@@ -58,7 +58,7 @@ namespace osu.Game.Tests.NonVisual
HitObject nested = new TestHitObject(HitWindows.Empty);
firstObject.AddNested(nested);
var secondObject = new TestHitObject(new HitWindows());
var secondObject = new TestHitObject(new DefaultHitWindows());
testDrawableRuleset.HitObjects = new List<HitObject> { firstObject, secondObject };
Assert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, secondObject.HitWindows);

View File

@@ -65,30 +65,30 @@ namespace osu.Game.Tests.Visual.Gameplay
{
new HitCircle
{
HitWindows = new HitWindows(),
HitWindows = new DefaultHitWindows(),
StartTime = t += spacing,
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
},
new HitCircle
{
HitWindows = new HitWindows(),
HitWindows = new DefaultHitWindows(),
StartTime = t += spacing,
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) }
},
new HitCircle
{
HitWindows = new HitWindows(),
HitWindows = new DefaultHitWindows(),
StartTime = t += spacing,
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) },
},
new HitCircle
{
HitWindows = new HitWindows(),
HitWindows = new DefaultHitWindows(),
StartTime = t += spacing,
},
new Slider
{
HitWindows = new HitWindows(),
HitWindows = new DefaultHitWindows(),
StartTime = t += spacing,
Path = new SliderPath(PathType.LINEAR, new[] { Vector2.Zero, Vector2.UnitY * 200 }),
Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT) },

View File

@@ -92,8 +92,8 @@ namespace osu.Game.Beatmaps
/// </list>
/// </param>
/// <returns>Value to which the difficulty value maps in the specified range.</returns>
static double DifficultyRange(double difficulty, (double od0, double od5, double od10) range)
=> DifficultyRange(difficulty, range.od0, range.od5, range.od10);
static double DifficultyRange(double difficulty, DifficultyRange range)
=> DifficultyRange(difficulty, range.Min, range.Mid, range.Max);
/// <summary>
/// Inverse function to <see cref="DifficultyRange(double,double,double,double)"/>.
@@ -110,5 +110,23 @@ namespace osu.Game.Beatmaps
? (difficultyValue - diff5) / (diff10 - diff5) * 5 + 5
: (difficultyValue - diff5) / (diff5 - diff0) * 5 + 5;
}
/// <summary>
/// Inverse function to <see cref="DifficultyRange(double,osu.Game.Beatmaps.DifficultyRange)"/>.
/// Maps a value returned by the function above back to the difficulty that produced it.
/// </summary>
/// <param name="difficultyValue">The difficulty-dependent value to be unmapped.</param>
/// <param name="range">Minimum of the resulting range which will be achieved by a difficulty value of 0.</param>
/// <returns>Value to which the difficulty value maps in the specified range.</returns>
static double InverseDifficultyRange(double difficultyValue, DifficultyRange range)
=> InverseDifficultyRange(difficultyValue, range.Min, range.Mid, range.Max);
}
/// <summary>
/// Represents a piecewise-linear difficulty curve for a given gameplay quantity.
/// </summary>
/// <param name="Min">Minimum of the resulting range which will be achieved by a difficulty value of 0.</param>
/// <param name="Mid">Midpoint of the resulting range which will be achieved by a difficulty value of 5.</param>
/// <param name="Max">Maximum of the resulting range which will be achieved by a difficulty value of 10.</param>
public record struct DifficultyRange(double Min, double Mid, double Max);
}

View File

@@ -192,7 +192,7 @@ namespace osu.Game.Rulesets.Objects
/// </para>
/// </summary>
[NotNull]
protected virtual HitWindows CreateHitWindows() => new HitWindows();
protected virtual HitWindows CreateHitWindows() => new DefaultHitWindows();
/// <summary>
/// The maximum offset from the end time of <see cref="HitObject"/> at which this <see cref="HitObject"/> can be judged.

View File

@@ -0,0 +1,66 @@
// 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.
using System;
using osu.Game.Beatmaps;
namespace osu.Game.Rulesets.Scoring
{
/// <summary>
/// An example implementation of <see cref="HitWindows"/>.
/// Not meaningfully used, provided mostly as a reference to ruleset implementors.
/// </summary>
public class DefaultHitWindows : HitWindows
{
private static readonly DifficultyRange perfect_window_range = new DifficultyRange(22.4D, 19.4D, 13.9D);
private static readonly DifficultyRange great_window_range = new DifficultyRange(64, 49, 34);
private static readonly DifficultyRange good_window_range = new DifficultyRange(97, 82, 67);
private static readonly DifficultyRange ok_window_range = new DifficultyRange(127, 112, 97);
private static readonly DifficultyRange meh_window_range = new DifficultyRange(151, 136, 121);
private static readonly DifficultyRange miss_window_range = new DifficultyRange(188, 173, 158);
private double perfect;
private double great;
private double good;
private double ok;
private double meh;
private double miss;
public override void SetDifficulty(double difficulty)
{
perfect = IBeatmapDifficultyInfo.DifficultyRange(difficulty, perfect_window_range);
great = IBeatmapDifficultyInfo.DifficultyRange(difficulty, great_window_range);
good = IBeatmapDifficultyInfo.DifficultyRange(difficulty, good_window_range);
ok = IBeatmapDifficultyInfo.DifficultyRange(difficulty, ok_window_range);
meh = IBeatmapDifficultyInfo.DifficultyRange(difficulty, meh_window_range);
miss = IBeatmapDifficultyInfo.DifficultyRange(difficulty, miss_window_range);
}
public override double WindowFor(HitResult result)
{
switch (result)
{
case HitResult.Perfect:
return perfect;
case HitResult.Great:
return great;
case HitResult.Good:
return good;
case HitResult.Ok:
return ok;
case HitResult.Meh:
return meh;
case HitResult.Miss:
return miss;
default:
throw new ArgumentOutOfRangeException(nameof(result), result, null);
}
}
}
}

View File

@@ -5,7 +5,6 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Scoring
@@ -13,35 +12,19 @@ namespace osu.Game.Rulesets.Scoring
/// <summary>
/// A structure containing timing data for hit window based gameplay.
/// </summary>
public class HitWindows
public abstract class HitWindows
{
private static readonly DifficultyRange[] base_ranges =
{
new DifficultyRange(HitResult.Perfect, 22.4D, 19.4D, 13.9D),
new DifficultyRange(HitResult.Great, 64, 49, 34),
new DifficultyRange(HitResult.Good, 97, 82, 67),
new DifficultyRange(HitResult.Ok, 127, 112, 97),
new DifficultyRange(HitResult.Meh, 151, 136, 121),
new DifficultyRange(HitResult.Miss, 188, 173, 158),
};
private double perfect;
private double great;
private double good;
private double ok;
private double meh;
private double miss;
/// <summary>
/// An empty <see cref="HitWindows"/> with only <see cref="HitResult.Miss"/> and <see cref="HitResult.Perfect"/>.
/// No time values are provided (meaning instantaneous hit or miss).
/// </summary>
public static HitWindows Empty { get; } = new EmptyHitWindows();
public HitWindows()
protected HitWindows()
{
Debug.Assert(GetRanges().Any(r => r.Result == HitResult.Miss), $"{nameof(GetRanges)} should always contain {nameof(HitResult.Miss)}");
Debug.Assert(GetRanges().Any(r => r.Result != HitResult.Miss), $"{nameof(GetRanges)} should always contain at least one result type other than {nameof(HitResult.Miss)}.");
var availableWindows = GetAllAvailableWindows();
Debug.Assert(availableWindows.Any(r => r.result == HitResult.Miss), $"{nameof(GetAllAvailableWindows)} should always contain {nameof(HitResult.Miss)}");
Debug.Assert(availableWindows.Any(r => r.result != HitResult.Miss), $"{nameof(GetAllAvailableWindows)} should always contain at least one result type other than {nameof(HitResult.Miss)}.");
}
/// <summary>
@@ -64,7 +47,7 @@ namespace osu.Game.Rulesets.Scoring
/// </summary>
public IEnumerable<(HitResult result, double length)> GetAllAvailableWindows()
{
for (var result = HitResult.Meh; result <= HitResult.Perfect; ++result)
for (var result = HitResult.Miss; result <= HitResult.Perfect; ++result)
{
if (IsHitResultAllowed(result))
yield return (result, WindowFor(result));
@@ -82,40 +65,7 @@ namespace osu.Game.Rulesets.Scoring
/// Sets hit windows with values that correspond to a difficulty parameter.
/// </summary>
/// <param name="difficulty">The parameter.</param>
public void SetDifficulty(double difficulty)
{
foreach (var range in GetRanges())
{
double value = IBeatmapDifficultyInfo.DifficultyRange(difficulty, (range.Min, range.Average, range.Max));
switch (range.Result)
{
case HitResult.Miss:
miss = value;
break;
case HitResult.Meh:
meh = value;
break;
case HitResult.Ok:
ok = value;
break;
case HitResult.Good:
good = value;
break;
case HitResult.Great:
great = value;
break;
case HitResult.Perfect:
perfect = value;
break;
}
}
}
public abstract void SetDifficulty(double difficulty);
/// <summary>
/// Retrieves the <see cref="HitResult"/> for a time offset.
@@ -141,35 +91,7 @@ namespace osu.Game.Rulesets.Scoring
/// </summary>
/// <param name="result">The expected <see cref="HitResult"/>.</param>
/// <returns>One half of the hit window for <paramref name="result"/>.</returns>
public double WindowFor(HitResult result)
{
if (!IsHitResultAllowed(result))
throw new ArgumentOutOfRangeException(nameof(result), result, $@"{result} is not an allowed result.");
switch (result)
{
case HitResult.Perfect:
return perfect;
case HitResult.Great:
return great;
case HitResult.Good:
return good;
case HitResult.Ok:
return ok;
case HitResult.Meh:
return meh;
case HitResult.Miss:
return miss;
default:
throw new ArgumentOutOfRangeException(nameof(result), result, null);
}
}
public abstract double WindowFor(HitResult result);
/// <summary>
/// Given a time offset, whether the <see cref="HitObject"/> can ever be hit in the future with a non-<see cref="HitResult.Miss"/> result.
@@ -179,41 +101,13 @@ namespace osu.Game.Rulesets.Scoring
/// <returns>Whether the <see cref="HitObject"/> can be hit at any point in the future from this time offset.</returns>
public bool CanBeHit(double timeOffset) => timeOffset <= WindowFor(LowestSuccessfulHitResult());
/// <summary>
/// Retrieve a valid list of <see cref="DifficultyRange"/>s representing hit windows.
/// Defaults are provided but can be overridden to customise for a ruleset.
/// </summary>
protected virtual DifficultyRange[] GetRanges() => base_ranges;
private class EmptyHitWindows : HitWindows
{
private static readonly DifficultyRange[] ranges =
{
new DifficultyRange(HitResult.Perfect, 0, 0, 0),
new DifficultyRange(HitResult.Miss, 0, 0, 0),
};
public override bool IsHitResultAllowed(HitResult result) => true;
protected override DifficultyRange[] GetRanges() => ranges;
}
}
public override void SetDifficulty(double difficulty) { }
public struct DifficultyRange
{
public readonly HitResult Result;
public double Min;
public double Average;
public double Max;
public DifficultyRange(HitResult result, double min, double average, double max)
{
Result = result;
Min = min;
Average = average;
Max = max;
public override double WindowFor(HitResult result) => 0;
}
}
}

View File

@@ -87,7 +87,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
const int bar_width = 2;
const float chevron_size = 8;
hitWindows = HitWindows.GetAllAvailableWindows().ToArray();
hitWindows = HitWindows.GetAllAvailableWindows().Where(w => w.result.IsHit()).ToArray();
InternalChild = new Container
{

View File

@@ -226,7 +226,7 @@ namespace osu.Game.Screens.Utility
HitEvent = new HitEvent(Clock.CurrentTime - HitTime, 1.0, HitResult.Good, new HitObject
{
HitWindows = new HitWindows(),
HitWindows = new DefaultHitWindows(),
}, null, null);
Hit?.Invoke(HitEvent.Value);

View File

@@ -188,7 +188,7 @@ namespace osu.Game.Screens.Utility
HitEvent = new HitEvent(Clock.CurrentTime - HitTime, 1.0, HitResult.Good, new HitObject
{
HitWindows = new HitWindows(),
HitWindows = new DefaultHitWindows(),
}, null, null);
Hit?.Invoke(HitEvent.Value);