mirror of
https://github.com/ppy/osu.git
synced 2026-05-13 19:54:15 +08:00
Adjust CheckFewHitsounds verify check based on feedback (#37466)
commit 1 makes sure breaks are skipped in calculation to avoid false positives ([reported internally](https://discord.com/channels/90072389919997952/1259818301517725707/1491051558081925170)) commit 2 makes the check only available in osu! and osu!catch as it's not relevant in: - osu!taiko, as it usually only triggers on valid mono-mapping (section with all dons for example). - osu!mania, given hitsounding is optional there. - ([brief internal discussion](https://discord.com/channels/90072389919997952/1259818301517725707/1496265253061660793))
This commit is contained in:
@@ -14,6 +14,9 @@ namespace osu.Game.Rulesets.Catch.Edit
|
||||
{
|
||||
private readonly List<ICheck> checks = new List<ICheck>
|
||||
{
|
||||
// Audio
|
||||
new CheckCatchFewHitsounds(),
|
||||
|
||||
// Compose
|
||||
new CheckBananaShowerGap(),
|
||||
new CheckConcurrentObjects(),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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 osu.Game.Rulesets.Edit.Checks;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Catch.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Catch.Edit.Checks
|
||||
{
|
||||
public class CheckCatchFewHitsounds : CheckFewHitsounds
|
||||
{
|
||||
protected override bool IsExcludedFromHitsounding(HitObject hitObject) => hitObject is BananaShower;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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 osu.Game.Rulesets.Edit.Checks;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Edit.Checks
|
||||
{
|
||||
public class CheckOsuFewHitsounds : CheckFewHitsounds
|
||||
{
|
||||
protected override bool IsExcludedFromHitsounding(HitObject hitObject) => hitObject is Spinner;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
{
|
||||
private readonly List<ICheck> checks = new List<ICheck>
|
||||
{
|
||||
// Audio
|
||||
new CheckOsuFewHitsounds(),
|
||||
|
||||
// Compose
|
||||
new CheckOffscreenObjects(),
|
||||
new CheckTooShortSpinners(),
|
||||
|
||||
@@ -7,9 +7,11 @@ using NUnit.Framework;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Beatmaps.Timing;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Edit.Checks;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Osu.Edit.Checks;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Tests.Beatmaps;
|
||||
|
||||
@@ -18,7 +20,7 @@ namespace osu.Game.Tests.Editing.Checks
|
||||
[TestFixture]
|
||||
public class CheckFewHitsoundsTest
|
||||
{
|
||||
private CheckFewHitsounds check = null!;
|
||||
private CheckOsuFewHitsounds check = null!;
|
||||
|
||||
private List<HitSampleInfo> notHitsounded = null!;
|
||||
private List<HitSampleInfo> hitsounded = null!;
|
||||
@@ -26,7 +28,7 @@ namespace osu.Game.Tests.Editing.Checks
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
check = new CheckFewHitsounds();
|
||||
check = new CheckOsuFewHitsounds();
|
||||
notHitsounded = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) };
|
||||
hitsounded = new List<HitSampleInfo>
|
||||
{
|
||||
@@ -82,6 +84,43 @@ namespace osu.Game.Tests.Editing.Checks
|
||||
assertOk(hitObjects);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestRarelyHitsoundedLongWallTimeMostlyBreak()
|
||||
{
|
||||
var hitObjects = new List<HitObject>
|
||||
{
|
||||
new HitCircle { StartTime = 0, Samples = hitsounded },
|
||||
new HitCircle { StartTime = 1000, Samples = notHitsounded },
|
||||
new HitCircle { StartTime = 2000, Samples = notHitsounded },
|
||||
new HitCircle { StartTime = 3000, Samples = notHitsounded },
|
||||
new HitCircle { StartTime = 4000, Samples = notHitsounded },
|
||||
new HitCircle { StartTime = 5000, Samples = notHitsounded },
|
||||
new HitCircle { StartTime = 10000, Samples = hitsounded },
|
||||
};
|
||||
|
||||
// 10s since last hitsound, but 6s overlap a break → 4s without hitsounds (below warning threshold).
|
||||
assertOk(hitObjects, new BreakPeriod(4000, 10000));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestRarelyHitsoundedLongWallTimeMostlySpinner()
|
||||
{
|
||||
var hitObjects = new List<HitObject>
|
||||
{
|
||||
new HitCircle { StartTime = 0, Samples = hitsounded },
|
||||
new HitCircle { StartTime = 200, Samples = notHitsounded },
|
||||
new HitCircle { StartTime = 400, Samples = notHitsounded },
|
||||
new HitCircle { StartTime = 600, Samples = notHitsounded },
|
||||
new HitCircle { StartTime = 800, Samples = notHitsounded },
|
||||
new HitCircle { StartTime = 1000, Samples = notHitsounded },
|
||||
new Spinner { StartTime = 1200, EndTime = 21200, Samples = notHitsounded },
|
||||
new HitCircle { StartTime = 21400, Samples = hitsounded },
|
||||
};
|
||||
|
||||
// 21.4s since last hitsound, but 20s overlap a spinner → 1.4s without hitsounds.
|
||||
assertOk(hitObjects);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestLightlyHitsounded()
|
||||
{
|
||||
@@ -194,9 +233,9 @@ namespace osu.Game.Tests.Editing.Checks
|
||||
assertOk(hitObjects);
|
||||
}
|
||||
|
||||
private void assertOk(List<HitObject> hitObjects)
|
||||
private void assertOk(List<HitObject> hitObjects, params BreakPeriod[] breaks)
|
||||
{
|
||||
Assert.That(check.Run(getContext(hitObjects)), Is.Empty);
|
||||
Assert.That(check.Run(getContext(hitObjects, breaks)), Is.Empty);
|
||||
}
|
||||
|
||||
private void assertLongPeriodProblem(List<HitObject> hitObjects, int count = 1)
|
||||
@@ -231,10 +270,13 @@ namespace osu.Game.Tests.Editing.Checks
|
||||
Assert.That(issues.Any(issue => issue.Template is CheckFewHitsounds.IssueTemplateNoHitsounds));
|
||||
}
|
||||
|
||||
private BeatmapVerifierContext getContext(List<HitObject> hitObjects)
|
||||
private BeatmapVerifierContext getContext(List<HitObject> hitObjects, params BreakPeriod[] breaks)
|
||||
{
|
||||
var beatmap = new Beatmap<HitObject> { HitObjects = hitObjects };
|
||||
|
||||
foreach (var b in breaks)
|
||||
beatmap.Breaks.Add(b);
|
||||
|
||||
return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ namespace osu.Game.Rulesets.Edit
|
||||
new CheckAudioPresence(),
|
||||
new CheckAudioQuality(),
|
||||
new CheckMutedObjects(),
|
||||
new CheckFewHitsounds(),
|
||||
new CheckTooShortAudioFiles(),
|
||||
new CheckAudioInVideo(),
|
||||
new CheckDelayedHitsounds(),
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
// 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 System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Beatmaps.Timing;
|
||||
using osu.Game.Rulesets.Edit.Checks.Components;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Edit.Checks
|
||||
{
|
||||
public class CheckFewHitsounds : ICheck
|
||||
public abstract class CheckFewHitsounds : ICheck
|
||||
{
|
||||
/// <summary>
|
||||
/// 2 measures (4/4) of 120 BPM, typically makes up a few patterns in the map.
|
||||
@@ -45,12 +47,21 @@ namespace osu.Game.Rulesets.Edit.Checks
|
||||
private bool mapHasHitsounds;
|
||||
private int objectsWithoutHitsounds;
|
||||
private double lastHitsoundTime;
|
||||
private IReadOnlyList<(double StartTime, double EndTime)> excludedTimeRanges = Array.Empty<(double StartTime, double EndTime)>();
|
||||
|
||||
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
|
||||
{
|
||||
if (!context.CurrentDifficulty.Playable.HitObjects.Any())
|
||||
yield break;
|
||||
|
||||
excludedTimeRanges = context.CurrentDifficulty.Playable.Breaks
|
||||
.Select(b => (b.StartTime, b.EndTime))
|
||||
.Concat(context.CurrentDifficulty.Playable.HitObjects
|
||||
.Where(IsExcludedFromHitsounding)
|
||||
.Select(h => (h.StartTime, EndTime: h.GetEndTime())))
|
||||
.Where(period => period.EndTime > period.StartTime)
|
||||
.ToList();
|
||||
|
||||
mapHasHitsounds = false;
|
||||
objectsWithoutHitsounds = 0;
|
||||
lastHitsoundTime = context.CurrentDifficulty.Playable.HitObjects.First().StartTime;
|
||||
@@ -61,9 +72,13 @@ namespace osu.Game.Rulesets.Edit.Checks
|
||||
{
|
||||
// Samples play on the end of objects. Some objects have nested objects to accomplish playing them elsewhere (e.g. slider head/repeat).
|
||||
foreach (var nestedHitObject in hitObject.NestedHitObjects)
|
||||
hitObjectsIncludingNested.Add(nestedHitObject);
|
||||
{
|
||||
if (!IsExcludedFromHitsounding(nestedHitObject))
|
||||
hitObjectsIncludingNested.Add(nestedHitObject);
|
||||
}
|
||||
|
||||
hitObjectsIncludingNested.Add(hitObject);
|
||||
if (!IsExcludedFromHitsounding(hitObject))
|
||||
hitObjectsIncludingNested.Add(hitObject);
|
||||
}
|
||||
|
||||
var hitObjectsByEndTime = hitObjectsIncludingNested.OrderBy(o => o.GetEndTime()).ToList();
|
||||
@@ -94,7 +109,7 @@ namespace osu.Game.Rulesets.Edit.Checks
|
||||
// If there are no hitsounds we let the "No hitsounds" template take precedence.
|
||||
if (hasHitsound || (isLastObject && mapHasHitsounds))
|
||||
{
|
||||
double timeWithoutHitsounds = time - lastHitsoundTime;
|
||||
double timeWithoutHitsounds = getTimeWithoutHitsounds(lastHitsoundTime, time);
|
||||
|
||||
if (timeWithoutHitsounds > problem_threshold_time && objectsWithoutHitsounds > problem_threshold_objects)
|
||||
yield return new IssueTemplateLongPeriodProblem(this).Create(lastHitsoundTime, timeWithoutHitsounds);
|
||||
@@ -114,6 +129,31 @@ namespace osu.Game.Rulesets.Edit.Checks
|
||||
++objectsWithoutHitsounds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Milliseconds between <paramref name="start"/> and <paramref name="end"/> that are not covered by a <see cref="BreakPeriod"/> or excluded object duration.
|
||||
/// </summary>
|
||||
private double getTimeWithoutHitsounds(double start, double end)
|
||||
{
|
||||
if (end <= start)
|
||||
return 0;
|
||||
|
||||
double duration = end - start;
|
||||
|
||||
foreach (var period in excludedTimeRanges)
|
||||
{
|
||||
double overlapStart = Math.Max(start, period.StartTime);
|
||||
double overlapEnd = Math.Min(end, period.EndTime);
|
||||
|
||||
if (overlapEnd > overlapStart)
|
||||
duration -= overlapEnd - overlapStart;
|
||||
}
|
||||
|
||||
return Math.Max(0, duration);
|
||||
}
|
||||
|
||||
// Intended to be overridden by ruleset-specific objects e.g. spinners, banana showers.
|
||||
protected virtual bool IsExcludedFromHitsounding(HitObject hitObject) => false;
|
||||
|
||||
private bool isHitsound(HitSampleInfo sample) => HitSampleInfo.ALL_ADDITIONS.Any(sample.Name.Contains);
|
||||
private bool isHitnormal(HitSampleInfo sample) => sample.Name.Contains(HitSampleInfo.HIT_NORMAL);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user