// 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 JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Lists; using osu.Framework.Utils; using osu.Game.Screens.Edit; using osu.Game.Utils; namespace osu.Game.Beatmaps.ControlPoints { [Serializable] [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] public class ControlPointInfo : IDeepCloneable { /// /// Invoked on any change to the set of control points. /// [CanBeNull] public event Action ControlPointsChanged; private void raiseControlPointsChanged([CanBeNull] ControlPoint _ = null) => ControlPointsChanged?.Invoke(); /// /// All control points grouped by time. /// [JsonProperty] public IBindableList Groups => groups; private readonly BindableList groups = new BindableList(); /// /// All timing points. /// [JsonProperty] public IReadOnlyList TimingPoints => timingPoints; private readonly SortedList timingPoints = new SortedList(Comparer.Default); /// /// All effect points. /// [JsonProperty] public IReadOnlyList EffectPoints => effectPoints; private readonly SortedList effectPoints = new SortedList(Comparer.Default); /// /// All control points, of all types. /// [JsonIgnore] public IEnumerable AllControlPoints => Groups.SelectMany(g => g.ControlPoints).ToArray(); /// /// Finds the effect control point that is active at . /// /// The time to find the effect control point at. /// The effect control point. [NotNull] public EffectControlPoint EffectPointAt(double time) => BinarySearchWithFallback(EffectPoints, time, EffectControlPoint.DEFAULT); /// /// Finds the timing control point that is active at . /// /// The time to find the timing control point at. /// The timing control point. [NotNull] public TimingControlPoint TimingPointAt(double time) => BinarySearchWithFallback(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : TimingControlPoint.DEFAULT); /// /// Finds the maximum BPM represented by any timing control point. /// [JsonIgnore] public double BPMMaximum => 60000 / (TimingPoints.MinBy(c => c.BeatLength) ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Finds the minimum BPM represented by any timing control point. /// [JsonIgnore] public double BPMMinimum => 60000 / (TimingPoints.MaxBy(c => c.BeatLength) ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Remove all s and return to a pristine state. /// public virtual void Clear() { groups.Clear(); timingPoints.Clear(); effectPoints.Clear(); } /// /// Add a new . Note that the provided control point may not be added if the correct state is already present at the provided time. /// /// The time at which the control point should be added. /// The control point to add. /// Whether the control point was added. public bool Add(double time, ControlPoint controlPoint) { if (CheckAlreadyExisting(time, controlPoint)) return false; GroupAt(time, true).Add(controlPoint); return true; } public ControlPointGroup GroupAt(double time, bool addIfNotExisting = false) { var newGroup = new ControlPointGroup(time); int i = groups.BinarySearch(newGroup); if (i >= 0) return groups[i]; if (addIfNotExisting) { newGroup.ItemAdded += GroupItemAdded; newGroup.ItemChanged += raiseControlPointsChanged; newGroup.ItemRemoved += GroupItemRemoved; groups.Insert(~i, newGroup); return newGroup; } return null; } public void RemoveGroup(ControlPointGroup group) { foreach (var item in group.ControlPoints.ToArray()) group.Remove(item); group.ItemAdded -= GroupItemAdded; group.ItemChanged -= raiseControlPointsChanged; group.ItemRemoved -= GroupItemRemoved; groups.Remove(group); } /// /// Returns the time on the given beat divisor closest to the given time. /// /// The time to find the closest snapped time to. /// The beat divisor to snap to. /// An optional reference point to use for timing point lookup. public double GetClosestSnappedTime(double time, int beatDivisor, double? referenceTime = null) { var timingPoint = TimingPointAt(referenceTime ?? time); return getClosestSnappedTime(timingPoint, time, beatDivisor); } /// /// Returns the time on *ANY* valid beat divisor, favouring the divisor closest to the given time. /// /// The time to find the closest snapped time to. public double GetClosestSnappedTime(double time) => GetClosestSnappedTime(time, GetClosestBeatDivisor(time)); /// /// Returns the beat snap divisor closest to the given time. If two are equally close, the smallest divisor is returned. /// /// The time to find the closest beat snap divisor to. /// An optional reference point to use for timing point lookup. public int GetClosestBeatDivisor(double time, double? referenceTime = null) { TimingControlPoint timingPoint = TimingPointAt(referenceTime ?? time); int closestDivisor = 0; double closestTime = double.MaxValue; foreach (int divisor in BindableBeatDivisor.PREDEFINED_DIVISORS) { double distanceFromSnap = Math.Abs(time - getClosestSnappedTime(timingPoint, time, divisor)); if (Precision.DefinitelyBigger(closestTime, distanceFromSnap)) { closestDivisor = divisor; closestTime = distanceFromSnap; } } return closestDivisor; } private static double getClosestSnappedTime(TimingControlPoint timingPoint, double time, int beatDivisor) { double beatLength = timingPoint.BeatLength / beatDivisor; double beats = (Math.Max(time, 0) - timingPoint.Time) / beatLength; int roundedBeats = (int)Math.Round(beats, MidpointRounding.AwayFromZero); double snappedTime = timingPoint.Time + roundedBeats * beatLength; if (snappedTime >= 0) return snappedTime; return snappedTime + beatLength; } /// /// Binary searches one of the control point lists to find the active control point at . /// Includes logic for returning a specific point when no matching point is found. /// /// The list to search. /// The time to find the control point at. /// The control point to use when is before any control points. /// The active control point at , or a fallback if none found. public static T BinarySearchWithFallback(IReadOnlyList list, double time, T fallback) where T : class, IControlPoint { return BinarySearch(list, time) ?? fallback; } /// /// Binary searches one of the control point lists to find the active control point at . /// /// The list to search. /// The time to find the control point at. /// The active control point at . Will return null if there are no control points, or if the time is before the first control point. public static T BinarySearch(IReadOnlyList list, double time) where T : class, IControlPoint { ArgumentNullException.ThrowIfNull(list); if (list.Count == 0) return null; if (time < list[0].Time) return null; if (time >= list[^1].Time) return list[^1]; int l = 0; int r = list.Count - 2; while (l <= r) { int pivot = l + ((r - l) >> 1); if (list[pivot].Time < time) l = pivot + 1; else if (list[pivot].Time > time) r = pivot - 1; else return list[pivot]; } // l will be the first control point with Time > time, but we want the one before it return list[l - 1]; } /// /// Check whether should be added. /// /// The time to find the timing control point at. /// A point to be added. /// Whether the new point should be added. protected virtual bool CheckAlreadyExisting(double time, ControlPoint newPoint) { ControlPoint existing = null; switch (newPoint) { case TimingControlPoint: // Timing points are a special case and need to be added regardless of fallback availability. existing = BinarySearch(TimingPoints, time); break; case EffectControlPoint: existing = EffectPointAt(time); break; } return newPoint?.IsRedundant(existing) == true; } protected virtual void GroupItemAdded(ControlPoint controlPoint) { switch (controlPoint) { case TimingControlPoint typed: timingPoints.Add(typed); break; case EffectControlPoint typed: effectPoints.Add(typed); break; default: throw new ArgumentException($"A control point of unexpected type {controlPoint.GetType()} was added to this {nameof(ControlPointInfo)}"); } raiseControlPointsChanged(); } protected virtual void GroupItemRemoved(ControlPoint controlPoint) { switch (controlPoint) { case TimingControlPoint typed: timingPoints.Remove(typed); break; case EffectControlPoint typed: effectPoints.Remove(typed); break; } raiseControlPointsChanged(); } public ControlPointInfo DeepClone() { var controlPointInfo = (ControlPointInfo)Activator.CreateInstance(GetType())!; foreach (var point in AllControlPoints) controlPointInfo.Add(point.Time, point.DeepClone()); return controlPointInfo; } } }