// Copyright (c) ppy Pty Ltd . 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; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Checks.Components; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Edit.Checks { public class CheckTimeDistanceEquality : ICheck { /// /// Two objects this many ms apart or more are skipped. (200 BPM 2/1) /// private const double pattern_lifetime = 600; /// /// Two objects this distance apart or less are skipped. /// private const double stack_leniency = 12; /// /// How long an observation is relevant for comparison. (120 BPM 8/1) /// private const double observation_lifetime = 4000; /// /// How different two delta times can be to still be compared. (240 BPM 1/16) /// private const double similar_time_leniency = 16; /// /// How many pixels are subtracted from the difference between current and expected distance. /// private const double distance_leniency_absolute_warning = 10; /// /// How much of the current distance that the difference can make out. /// private const double distance_leniency_percent_warning = 0.15; private const double distance_leniency_absolute_problem = 20; private const double distance_leniency_percent_problem = 0.3; public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Object too close or far away from previous"); public IEnumerable PossibleTemplates => new IssueTemplate[] { new IssueTemplateIrregularSpacingProblem(this), new IssueTemplateIrregularSpacingWarning(this) }; /// /// Represents an observation of the time and distance between two objects. /// private readonly struct ObservedTimeDistance { public readonly double ObservationTime; public readonly double DeltaTime; public readonly double Distance; public ObservedTimeDistance(double observationTime, double deltaTime, double distance) { ObservationTime = observationTime; DeltaTime = deltaTime; Distance = distance; } } public IEnumerable Run(BeatmapVerifierContext context) { if (context.InterpretedDifficulty > DifficultyRating.Normal) yield break; var prevObservedTimeDistances = new List(); var hitObjects = context.Beatmap.HitObjects; for (int i = 0; i < hitObjects.Count - 1; ++i) { if (!(hitObjects[i] is OsuHitObject hitObject) || hitObject is Spinner) continue; if (!(hitObjects[i + 1] is OsuHitObject nextHitObject) || nextHitObject is Spinner) continue; double deltaTime = nextHitObject.StartTime - hitObject.GetEndTime(); // Ignore objects that are far enough apart in time to not be considered the same pattern. if (deltaTime > pattern_lifetime) continue; // Relying on FastInvSqrt is probably good enough here. We'll be taking the difference between distances later, hence square not being sufficient. float distance = (hitObject.StackedEndPosition - nextHitObject.StackedPosition).LengthFast; // Ignore stacks and half-stacks, as these are close enough to where they can't be confused for being time-distanced. if (distance < stack_leniency) continue; var observedTimeDistance = new ObservedTimeDistance(nextHitObject.StartTime, deltaTime, distance); double expectedDistance = getExpectedDistance(prevObservedTimeDistances, observedTimeDistance); if (expectedDistance == 0) { // There was nothing relevant to compare to. prevObservedTimeDistances.Add(observedTimeDistance); continue; } if ((Math.Abs(expectedDistance - distance) - distance_leniency_absolute_problem) / distance > distance_leniency_percent_problem) yield return new IssueTemplateIrregularSpacingProblem(this).Create(expectedDistance, distance, hitObject, nextHitObject); else if ((Math.Abs(expectedDistance - distance) - distance_leniency_absolute_warning) / distance > distance_leniency_percent_warning) yield return new IssueTemplateIrregularSpacingWarning(this).Create(expectedDistance, distance, hitObject, nextHitObject); else { // We use `else` here to prevent issues from cascading; an object spaced too far could cause regular spacing to be considered "too short" otherwise. prevObservedTimeDistances.Add(observedTimeDistance); } } } private double getExpectedDistance(IEnumerable prevObservedTimeDistances, ObservedTimeDistance observedTimeDistance) { int observations = prevObservedTimeDistances.Count(); int count = 0; double sum = 0; // Looping this in reverse allows us to break before going through all elements, as we're only interested in the most recent ones. for (int i = observations - 1; i >= 0; --i) { var prevObservedTimeDistance = prevObservedTimeDistances.ElementAt(i); // Only consider observations within the last few seconds - this allows the map to build spacing up/down over time, but prevents it from being too sudden. if (observedTimeDistance.ObservationTime - prevObservedTimeDistance.ObservationTime > observation_lifetime) break; // Only consider observations which have a similar time difference - this leniency allows handling of multi-BPM maps which speed up/down slowly. if (Math.Abs(observedTimeDistance.DeltaTime - prevObservedTimeDistance.DeltaTime) > similar_time_leniency) break; count += 1; sum += prevObservedTimeDistance.Distance / Math.Max(prevObservedTimeDistance.DeltaTime, 1); } return sum / Math.Max(count, 1) * observedTimeDistance.DeltaTime; } public abstract class IssueTemplateIrregularSpacing : IssueTemplate { protected IssueTemplateIrregularSpacing(ICheck check, IssueType issueType) : base(check, issueType, "Expected {0:0} px spacing like previous objects, currently {1:0}.") { } public Issue Create(double expected, double actual, params HitObject[] hitObjects) => new Issue(hitObjects, this, expected, actual); } public class IssueTemplateIrregularSpacingProblem : IssueTemplateIrregularSpacing { public IssueTemplateIrregularSpacingProblem(ICheck check) : base(check, IssueType.Problem) { } } public class IssueTemplateIrregularSpacingWarning : IssueTemplateIrregularSpacing { public IssueTemplateIrregularSpacingWarning(ICheck check) : base(check, IssueType.Warning) { } } } }