diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs index d4d99e1019..c2589f11ef 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs @@ -163,6 +163,44 @@ public void TestMultipleControlPointDeselectionViaClickOutside() checkControlPointSelected(1, false); } + [Test] + public void TestAdjustLength() + { + AddStep("move mouse to drag marker", () => + { + Vector2 position = slider.Position + slider.Path.PositionAt(1) + new Vector2(60, 0); + InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position)); + }); + AddStep("start drag", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move mouse to control point 1", () => + { + Vector2 position = slider.Position + slider.Path.ControlPoints[1].Position + new Vector2(60, 0); + InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position)); + }); + AddStep("end adjust length", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("expected distance halved", + () => Precision.AlmostEquals(slider.Path.Distance, 172.2, 0.1)); + + AddStep("move mouse to drag marker", () => + { + Vector2 position = slider.Position + slider.Path.PositionAt(1) + new Vector2(60, 0); + InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position)); + }); + AddStep("start drag", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move mouse beyond last control point", () => + { + Vector2 position = slider.Position + slider.Path.ControlPoints[2].Position + new Vector2(100, 0); + InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position)); + }); + AddStep("end adjust length", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("expected distance is calculated distance", + () => Precision.AlmostEquals(slider.Path.Distance, slider.Path.CalculatedDistance, 0.1)); + + moveMouseToControlPoint(1); + AddAssert("expected distance is unchanged", + () => Precision.AlmostEquals(slider.Path.Distance, slider.Path.CalculatedDistance, 0.1)); + } + private void moveHitObject() { AddStep("move hitobject", () => diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs index bd3b4bbc54..247ceb4078 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs @@ -1,7 +1,10 @@ // 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 osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Objects; @@ -9,11 +12,28 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { public partial class SliderCircleOverlay : CompositeDrawable { + public SliderEndDragMarker? EndDragMarker { get; } + + public RectangleF VisibleQuad + { + get + { + var result = CirclePiece.ScreenSpaceDrawQuad.AABBFloat; + + if (endDragMarkerContainer == null) return result; + + var size = result.Size * 1.4f; + var location = result.TopLeft - result.Size * 0.2f; + return new RectangleF(location, size); + } + } + protected readonly HitCirclePiece CirclePiece; private readonly Slider slider; private readonly SliderPosition position; private readonly HitCircleOverlapMarker? marker; + private readonly Container? endDragMarkerContainer; public SliderCircleOverlay(Slider slider, SliderPosition position) { @@ -24,26 +44,49 @@ public SliderCircleOverlay(Slider slider, SliderPosition position) AddInternal(marker = new HitCircleOverlapMarker()); AddInternal(CirclePiece = new HitCirclePiece()); + + if (position == SliderPosition.End) + { + AddInternal(endDragMarkerContainer = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding(-2.5f), + Child = EndDragMarker = new SliderEndDragMarker() + }); + } } protected override void Update() { base.Update(); - var circle = position == SliderPosition.Start ? (HitCircle)slider.HeadCircle : slider.TailCircle; + var circle = position == SliderPosition.Start ? (HitCircle)slider.HeadCircle : + slider.RepeatCount % 2 == 0 ? slider.TailCircle : slider.LastRepeat!; CirclePiece.UpdateFrom(circle); marker?.UpdateFrom(circle); + + if (endDragMarkerContainer != null) + { + endDragMarkerContainer.Position = circle.Position; + endDragMarkerContainer.Scale = CirclePiece.Scale * 1.2f; + var diff = slider.Path.PositionAt(1) - slider.Path.PositionAt(0.99f); + endDragMarkerContainer.Rotation = float.RadiansToDegrees(MathF.Atan2(diff.Y, diff.X)); + } } public override void Hide() { CirclePiece.Hide(); + endDragMarkerContainer?.Hide(); } public override void Show() { CirclePiece.Show(); + endDragMarkerContainer?.Show(); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderEndDragMarker.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderEndDragMarker.cs new file mode 100644 index 0000000000..37383544dc --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderEndDragMarker.cs @@ -0,0 +1,84 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Lines; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders +{ + public partial class SliderEndDragMarker : SmoothPath + { + public Action? StartDrag { get; set; } + public Action? Drag { get; set; } + public Action? EndDrag { get; set; } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + var path = PathApproximator.CircularArcToPiecewiseLinear([ + new Vector2(0, OsuHitObject.OBJECT_RADIUS), + new Vector2(OsuHitObject.OBJECT_RADIUS, 0), + new Vector2(0, -OsuHitObject.OBJECT_RADIUS) + ]); + + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + PathRadius = 5; + Vertices = path; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + protected override bool OnDragStart(DragStartEvent e) + { + updateState(); + StartDrag?.Invoke(e); + return true; + } + + protected override void OnDrag(DragEvent e) + { + updateState(); + base.OnDrag(e); + Drag?.Invoke(e); + } + + protected override void OnDragEnd(DragEndEvent e) + { + updateState(); + EndDrag?.Invoke(); + base.OnDragEnd(e); + } + + private void updateState() + { + Colour = IsHovered || IsDragged ? colours.Red : colours.Yellow; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 013f790f65..42945295b8 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -1,13 +1,9 @@ // 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.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input; @@ -29,30 +25,29 @@ public partial class SliderPlacementBlueprint : PlacementBlueprint { public new Slider HitObject => (Slider)base.HitObject; - private SliderBodyPiece bodyPiece; - private HitCirclePiece headCirclePiece; - private HitCirclePiece tailCirclePiece; - private PathControlPointVisualiser controlPointVisualiser; + private SliderBodyPiece bodyPiece = null!; + private HitCirclePiece headCirclePiece = null!; + private HitCirclePiece tailCirclePiece = null!; + private PathControlPointVisualiser controlPointVisualiser = null!; - private InputManager inputManager; + private InputManager inputManager = null!; + + private PathControlPoint? cursor; private SliderPlacementState state; private PathControlPoint segmentStart; - private PathControlPoint cursor; + private int currentSegmentLength; private bool usingCustomSegmentType; - [Resolved(CanBeNull = true)] - [CanBeNull] - private IPositionSnapProvider positionSnapProvider { get; set; } + [Resolved] + private IPositionSnapProvider? positionSnapProvider { get; set; } - [Resolved(CanBeNull = true)] - [CanBeNull] - private IDistanceSnapProvider distanceSnapProvider { get; set; } + [Resolved] + private IDistanceSnapProvider? distanceSnapProvider { get; set; } - [Resolved(CanBeNull = true)] - [CanBeNull] - private FreehandSliderToolboxGroup freehandToolboxGroup { get; set; } + [Resolved] + private FreehandSliderToolboxGroup? freehandToolboxGroup { get; set; } private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder { Degree = 4 }; @@ -84,7 +79,8 @@ private void load() protected override void LoadComplete() { base.LoadComplete(); - inputManager = GetContainingInputManager(); + + inputManager = GetContainingInputManager()!; if (freehandToolboxGroup != null) { @@ -108,7 +104,7 @@ protected override void LoadComplete() } [Resolved] - private EditorBeatmap editorBeatmap { get; set; } + private EditorBeatmap editorBeatmap { get; set; } = null!; public override void UpdateTimeAndPosition(SnapResult result) { @@ -151,7 +147,7 @@ protected override bool OnMouseDown(MouseDownEvent e) case SliderPlacementState.ControlPoints: if (canPlaceNewControlPoint(out var lastPoint)) placeNewControlPoint(); - else + else if (lastPoint != null) beginNewSegment(lastPoint); break; @@ -162,9 +158,6 @@ protected override bool OnMouseDown(MouseDownEvent e) private void beginNewSegment(PathControlPoint lastPoint) { - // Transform the last point into a new segment. - Debug.Assert(lastPoint != null); - segmentStart = lastPoint; segmentStart.Type = PathType.LINEAR; @@ -384,7 +377,7 @@ private Vector2 getCursorPosition() /// /// The last-placed control point. May be null, but is not null if false is returned. /// Whether a new control point can be placed at the current position. - private bool canPlaceNewControlPoint([CanBeNull] out PathControlPoint lastPoint) + private bool canPlaceNewControlPoint(out PathControlPoint? lastPoint) { // We cannot rely on the ordering of drawable pieces, so find the respective drawable piece by searching for the last non-cursor control point. var last = HitObject.Path.ControlPoints.LastOrDefault(p => p != cursor); @@ -436,7 +429,7 @@ private void updateSliderPathFromBSplineBuilder() // Replace this segment with a circular arc if it is a reasonable substitute. var circleArcSegment = tryCircleArc(segment); - if (circleArcSegment is not null) + if (circleArcSegment != null) { HitObject.Path.ControlPoints.Add(new PathControlPoint(circleArcSegment[0], PathType.PERFECT_CURVE)); HitObject.Path.ControlPoints.Add(new PathControlPoint(circleArcSegment[1])); @@ -453,7 +446,7 @@ private void updateSliderPathFromBSplineBuilder() } } - private Vector2[] tryCircleArc(List segment) + private Vector2[]? tryCircleArc(List segment) { if (segment.Count < 3 || freehandToolboxGroup?.CircleThreshold.Value == 0) return null; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 2c675db263..1debb09099 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -1,13 +1,13 @@ // 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.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; @@ -33,27 +33,28 @@ public partial class SliderSelectionBlueprint : OsuSelectionBlueprint { protected new DrawableSlider DrawableObject => (DrawableSlider)base.DrawableObject; - protected SliderBodyPiece BodyPiece { get; private set; } - protected SliderCircleOverlay HeadOverlay { get; private set; } - protected SliderCircleOverlay TailOverlay { get; private set; } + protected SliderBodyPiece BodyPiece { get; private set; } = null!; + protected SliderCircleOverlay HeadOverlay { get; private set; } = null!; + protected SliderCircleOverlay TailOverlay { get; private set; } = null!; - [CanBeNull] - protected PathControlPointVisualiser ControlPointVisualiser { get; private set; } + protected PathControlPointVisualiser? ControlPointVisualiser { get; private set; } - [Resolved(CanBeNull = true)] - private IDistanceSnapProvider distanceSnapProvider { get; set; } + [Resolved] + private IDistanceSnapProvider? distanceSnapProvider { get; set; } - [Resolved(CanBeNull = true)] - private IPlacementHandler placementHandler { get; set; } + [Resolved] + private IPlacementHandler? placementHandler { get; set; } - [Resolved(CanBeNull = true)] - private EditorBeatmap editorBeatmap { get; set; } + [Resolved] + private EditorBeatmap? editorBeatmap { get; set; } - [Resolved(CanBeNull = true)] - private IEditorChangeHandler changeHandler { get; set; } + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } - [Resolved(CanBeNull = true)] - private BindableBeatDivisor beatDivisor { get; set; } + [Resolved] + private BindableBeatDivisor? beatDivisor { get; set; } + + private PathControlPoint? placementControlPoint; public override Quad SelectionQuad { @@ -61,6 +62,9 @@ public override Quad SelectionQuad { var result = BodyPiece.ScreenSpaceDrawQuad.AABBFloat; + result = RectangleF.Union(result, HeadOverlay.VisibleQuad); + result = RectangleF.Union(result, TailOverlay.VisibleQuad); + if (ControlPointVisualiser != null) { foreach (var piece in ControlPointVisualiser.Pieces) @@ -76,6 +80,11 @@ public override Quad SelectionQuad private readonly BindableList selectedObjects = new BindableList(); private readonly Bindable showHitMarkers = new Bindable(); + // Cached slider path which ignored the expected distance value. + private readonly Cached fullPathCache = new Cached(); + + private Vector2 lastRightClickPosition; + public SliderSelectionBlueprint(Slider slider) : base(slider) { @@ -91,6 +100,13 @@ private void load(OsuConfigManager config) TailOverlay = CreateCircleOverlay(HitObject, SliderPosition.End), }; + // tail will always have a non-null end drag marker. + Debug.Assert(TailOverlay.EndDragMarker != null); + + TailOverlay.EndDragMarker.StartDrag += startAdjustingLength; + TailOverlay.EndDragMarker.Drag += adjustLength; + TailOverlay.EndDragMarker.EndDrag += endAdjustLength; + config.BindWith(OsuSetting.EditorShowHitMarkers, showHitMarkers); } @@ -99,6 +115,7 @@ protected override void LoadComplete() base.LoadComplete(); controlPoints.BindTo(HitObject.Path.ControlPoints); + controlPoints.CollectionChanged += (_, _) => fullPathCache.Invalidate(); pathVersion.BindTo(HitObject.Path.Version); pathVersion.BindValueChanged(_ => editorBeatmap?.Update(HitObject)); @@ -123,7 +140,7 @@ public override bool HandleQuickDeletion() return false; hoveredControlPoint.IsSelected.Value = true; - ControlPointVisualiser.DeleteSelected(); + ControlPointVisualiser?.DeleteSelected(); return true; } @@ -141,7 +158,6 @@ protected override void Update() protected override bool OnHover(HoverEvent e) { updateVisualDefinition(); - return base.OnHover(e); } @@ -186,17 +202,16 @@ private void updateVisualDefinition() } } - private Vector2 rightClickPosition; - protected override bool OnMouseDown(MouseDownEvent e) { switch (e.Button) { case MouseButton.Right: - rightClickPosition = e.MouseDownPosition; + lastRightClickPosition = e.MouseDownPosition; return false; // Allow right click to be handled by context menu case MouseButton.Left: + // If there's more than two objects selected, ctrl+click should deselect if (e.ControlPressed && IsSelected && selectedObjects.Count < 2) { @@ -212,8 +227,134 @@ protected override bool OnMouseDown(MouseDownEvent e) return false; } - [CanBeNull] - private PathControlPoint placementControlPoint; + #region Length Adjustment (independent of path nodes) + + private Vector2 lengthAdjustMouseOffset; + private double oldDuration; + private double oldVelocityMultiplier; + private double desiredDistance; + private bool isAdjustingLength; + private bool adjustVelocityMomentary; + + private void startAdjustingLength(DragStartEvent e) + { + isAdjustingLength = true; + adjustVelocityMomentary = e.ShiftPressed; + lengthAdjustMouseOffset = ToLocalSpace(e.ScreenSpaceMouseDownPosition) - HitObject.Position - HitObject.Path.PositionAt(1); + oldDuration = HitObject.Path.Distance / HitObject.SliderVelocityMultiplier; + oldVelocityMultiplier = HitObject.SliderVelocityMultiplier; + changeHandler?.BeginChange(); + } + + private void endAdjustLength() + { + trimExcessControlPoints(HitObject.Path); + changeHandler?.EndChange(); + isAdjustingLength = false; + } + + private void adjustLength(MouseEvent e) => adjustLength(findClosestPathDistance(e), e.ShiftPressed); + + private void adjustLength(double proposedDistance, bool adjustVelocity) + { + desiredDistance = proposedDistance; + double proposedVelocity = oldVelocityMultiplier; + + if (adjustVelocity) + { + proposedVelocity = proposedDistance / oldDuration; + proposedDistance = MathHelper.Clamp(proposedDistance, 0.1 * oldDuration, 10 * oldDuration); + } + else + { + double minDistance = distanceSnapProvider?.GetBeatSnapDistanceAt(HitObject, false) * oldVelocityMultiplier ?? 1; + // Add a small amount to the proposed distance to make it easier to snap to the full length of the slider. + proposedDistance = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)proposedDistance + 1) ?? proposedDistance; + proposedDistance = MathHelper.Clamp(proposedDistance, minDistance, HitObject.Path.CalculatedDistance); + } + + if (Precision.AlmostEquals(proposedDistance, HitObject.Path.Distance) && Precision.AlmostEquals(proposedVelocity, HitObject.SliderVelocityMultiplier)) + return; + + HitObject.SliderVelocityMultiplier = proposedVelocity; + HitObject.Path.ExpectedDistance.Value = proposedDistance; + editorBeatmap?.Update(HitObject); + } + + /// + /// Trims control points from the end of the slider path which are not required to reach the expected end of the slider. + /// + /// The slider path to trim control points of. + private void trimExcessControlPoints(SliderPath sliderPath) + { + if (!sliderPath.ExpectedDistance.Value.HasValue) + return; + + double[] segmentEnds = sliderPath.GetSegmentEnds().ToArray(); + int segmentIndex = 0; + + for (int i = 1; i < sliderPath.ControlPoints.Count - 1; i++) + { + if (!sliderPath.ControlPoints[i].Type.HasValue) continue; + + if (Precision.AlmostBigger(segmentEnds[segmentIndex], 1, 1E-3)) + { + sliderPath.ControlPoints.RemoveRange(i + 1, sliderPath.ControlPoints.Count - i - 1); + sliderPath.ControlPoints[^1].Type = null; + break; + } + + segmentIndex++; + } + } + + /// + /// Finds the expected distance value for which the slider end is closest to the mouse position. + /// + private double findClosestPathDistance(MouseEvent e) + { + const double step1 = 10; + const double step2 = 0.1; + const double longer_distance_bias = 0.01; + + var desiredPosition = ToLocalSpace(e.ScreenSpaceMousePosition) - HitObject.Position - lengthAdjustMouseOffset; + + if (!fullPathCache.IsValid) + fullPathCache.Value = new SliderPath(HitObject.Path.ControlPoints.ToArray()); + + // Do a linear search to find the closest point on the path to the mouse position. + double bestValue = 0; + double minDistance = double.MaxValue; + + for (double d = 0; d <= fullPathCache.Value.CalculatedDistance; d += step1) + { + double t = d / fullPathCache.Value.CalculatedDistance; + double dist = Vector2.Distance(fullPathCache.Value.PositionAt(t), desiredPosition) - d * longer_distance_bias; + + if (dist >= minDistance) continue; + + minDistance = dist; + bestValue = d; + } + + // Do another linear search to fine-tune the result. + double maxValue = Math.Min(bestValue + step1, fullPathCache.Value.CalculatedDistance); + + for (double d = bestValue - step1; d <= maxValue; d += step2) + { + double t = d / fullPathCache.Value.CalculatedDistance; + double dist = Vector2.Distance(fullPathCache.Value.PositionAt(t), desiredPosition) - d * longer_distance_bias; + + if (dist >= minDistance) continue; + + minDistance = dist; + bestValue = d; + } + + return bestValue; + } + + #endregion protected override bool OnDragStart(DragStartEvent e) { @@ -255,9 +396,24 @@ protected override bool OnKeyDown(KeyDownEvent e) return true; } + if (isAdjustingLength && e.ShiftPressed != adjustVelocityMomentary) + { + adjustVelocityMomentary = e.ShiftPressed; + adjustLength(desiredDistance, adjustVelocityMomentary); + return true; + } + return false; } + protected override void OnKeyUp(KeyUpEvent e) + { + if (!IsSelected || !isAdjustingLength || e.ShiftPressed == adjustVelocityMomentary) return; + + adjustVelocityMomentary = e.ShiftPressed; + adjustLength(desiredDistance, adjustVelocityMomentary); + } + private PathControlPoint addControlPoint(Vector2 position) { position -= HitObject.Position; @@ -326,6 +482,9 @@ private void removeControlPoints(List toRemove) private void splitControlPoints(List controlPointsToSplitAt) { + if (editorBeatmap == null) + return; + // Arbitrary gap in milliseconds to put between split slider pieces const double split_gap = 100; @@ -432,7 +591,7 @@ private void convertToStream() new OsuMenuItem("Add control point", MenuItemType.Standard, () => { changeHandler?.BeginChange(); - addControlPoint(rightClickPosition); + addControlPoint(lastRightClickPosition); changeHandler?.EndChange(); }), new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream), diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index f542b7b5af..2b3bb18844 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -9,6 +9,7 @@ using osu.Game.Rulesets.Objects; using System.Linq; using System.Threading; +using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Caching; @@ -162,6 +163,10 @@ public double SliderVelocityMultiplier [JsonIgnore] public SliderTailCircle TailCircle { get; protected set; } + [JsonIgnore] + [CanBeNull] + public SliderRepeat LastRepeat { get; protected set; } + public Slider() { SamplesBindable.CollectionChanged += (_, _) => UpdateNestedSamples(); @@ -225,7 +230,7 @@ protected override void CreateNestedHitObjects(CancellationToken cancellationTok break; case SliderEventType.Repeat: - AddNested(new SliderRepeat(this) + AddNested(LastRepeat = new SliderRepeat(this) { RepeatIndex = e.SpanIndex, StartTime = StartTime + (e.SpanIndex + 1) * SpanDuration, @@ -248,6 +253,9 @@ private void updateNestedPositions() if (TailCircle != null) TailCircle.Position = EndPosition; + + if (LastRepeat != null) + LastRepeat.Position = RepeatCount % 2 == 0 ? Position : Position + Path.PositionAt(1); } protected void UpdateNestedSamples()