diff --git a/osu.Game.Rulesets.Catch/Edit/CatchBeatmapVerifier.cs b/osu.Game.Rulesets.Catch/Edit/CatchBeatmapVerifier.cs index 0783ec72e9..055c909fa9 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchBeatmapVerifier.cs @@ -14,6 +14,9 @@ namespace osu.Game.Rulesets.Catch.Edit { private readonly List checks = new List { + // Audio + new CheckCatchFewHitsounds(), + // Compose new CheckBananaShowerGap(), new CheckConcurrentObjects(), diff --git a/osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchFewHitsounds.cs b/osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchFewHitsounds.cs new file mode 100644 index 0000000000..907d090adc --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchFewHitsounds.cs @@ -0,0 +1,14 @@ +// Copyright (c) ppy Pty Ltd . 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; + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuFewHitsounds.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuFewHitsounds.cs new file mode 100644 index 0000000000..e6f51669ff --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuFewHitsounds.cs @@ -0,0 +1,14 @@ +// Copyright (c) ppy Pty Ltd . 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; + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs index 67fddfb8a4..e9fbcf972b 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs @@ -14,6 +14,9 @@ namespace osu.Game.Rulesets.Osu.Edit { private readonly List checks = new List { + // Audio + new CheckOsuFewHitsounds(), + // Compose new CheckOffscreenObjects(), new CheckTooShortSpinners(), diff --git a/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs b/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs index 01781b98ad..08b37602d6 100644 --- a/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs @@ -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 notHitsounded = null!; private List 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 { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }; hitsounded = new List { @@ -82,6 +84,43 @@ namespace osu.Game.Tests.Editing.Checks assertOk(hitObjects); } + [Test] + public void TestRarelyHitsoundedLongWallTimeMostlyBreak() + { + var hitObjects = new List + { + 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 + { + 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 hitObjects) + private void assertOk(List 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 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 hitObjects) + private BeatmapVerifierContext getContext(List hitObjects, params BreakPeriod[] breaks) { var beatmap = new Beatmap { HitObjects = hitObjects }; + foreach (var b in breaks) + beatmap.Breaks.Add(b); + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); } } diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index f780472f20..340ff7a226 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -25,7 +25,6 @@ namespace osu.Game.Rulesets.Edit new CheckAudioPresence(), new CheckAudioQuality(), new CheckMutedObjects(), - new CheckFewHitsounds(), new CheckTooShortAudioFiles(), new CheckAudioInVideo(), new CheckDelayedHitsounds(), diff --git a/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs b/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs index 941cebdb4f..ce588dc451 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs @@ -1,15 +1,17 @@ // 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.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 { /// /// 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 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; } + /// + /// Milliseconds between and that are not covered by a or excluded object duration. + /// + 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);