From c144cf188a37ebc965b25818938527c02013efc4 Mon Sep 17 00:00:00 2001 From: Arthur Araujo <90941580+64ArthurAraujo@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:57:42 -0300 Subject: [PATCH] Fix reversing straight perfect curve sliders positioning them weirdly (#36698) --- .../Editor/TestSceneSliderReversal.cs | 232 +++++++++++++++++- .../Rulesets/Objects/SliderPathExtensions.cs | 11 +- 2 files changed, 234 insertions(+), 9 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderReversal.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderReversal.cs index a44c16a2e0..058776c527 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderReversal.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderReversal.cs @@ -3,10 +3,13 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Tests.Beatmaps; @@ -30,6 +33,16 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor PathType.LINEAR, new Vector2(100, 0), new Vector2(100, 100) + ), + createPathSegment( + PathType.PERFECT_CURVE, + new Vector2(100.009f, -50.0009f), + new Vector2(200.0089f, -100) + ), + createPathSegment( + PathType.PERFECT_CURVE, + new Vector2(25, -50), + new Vector2(100, 75) ) }; @@ -48,9 +61,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [TestCase(0, 250)] [TestCase(0, 200)] - [TestCase(1, 120)] - [TestCase(1, 80)] - public void TestSliderReversal(int pathIndex, double length) + [TestCase(1, 120, false, false)] + [TestCase(1, 80, false, false)] + [TestCase(2, 250)] + [TestCase(2, 190)] + [TestCase(3, 250)] + [TestCase(3, 190)] + public void TestSliderReversal(int pathIndex, double length, bool assertEqualDistances = true, bool assertSliderReduction = true) { var controlPoints = paths[pathIndex]; @@ -90,6 +107,215 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor InputManager.ReleaseKey(Key.LControl); }); + if (pathIndex == 2) + { + AddRepeatStep("Reverse slider again", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }, 2); + } + + if (assertEqualDistances) + { + AddAssert("Middle control point has the same distance from start to end", () => + { + var pathControlPoints = selectedSlider.Path.ControlPoints; + float middleToStart = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[0].Position); + float middleToEnd = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[^1].Position); + + return Precision.AlmostEquals(middleToStart, middleToEnd, 1f); + }); + } + + AddAssert("Middle control point is not at start or end", () => + Vector2.Distance(selectedSlider.Path.ControlPoints[^2].Position, oldStartPos) > 1 && + Vector2.Distance(selectedSlider.Path.ControlPoints[^2].Position, oldEndPos) > 1 + ); + + AddAssert("Slider has correct length", () => + Precision.AlmostEquals(selectedSlider.Path.Distance, oldDistance)); + + AddAssert("Slider has correct start position", () => + Vector2.Distance(selectedSlider.Position, oldEndPos) < 1); + + AddAssert("Slider has correct end position", () => + Vector2.Distance(selectedSlider.EndPosition, oldStartPos) < 1); + + AddAssert("Control points have correct types", () => + { + var newControlPointTypes = selectedSlider.Path.ControlPoints.Select(p => p.Type).ToArray(); + + return oldControlPointTypes.Take(newControlPointTypes.Length).SequenceEqual(newControlPointTypes); + }); + + if (assertSliderReduction) + { + AddStep("Move to marker", () => + { + var marker = this.ChildrenOfType().Single(); + var markerPos = (marker.ScreenSpaceDrawQuad.TopRight + marker.ScreenSpaceDrawQuad.BottomRight) / 2; + // sometimes the cursor may miss the marker's hitbox so we + // add a little offset here to be sure it lands in a clickable position. + var position = new Vector2(markerPos.X + 2f, markerPos.Y); + InputManager.MoveMouseTo(position); + }); + AddStep("Click", () => InputManager.PressButton(MouseButton.Left)); + AddStep("Reduce slider", () => + { + var middleControlPoint = this.ChildrenOfType>().ToArray()[^2]; + InputManager.MoveMouseTo(middleControlPoint); + }); + AddStep("Release click", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddStep("Save half slider info", () => + { + oldStartPos = selectedSlider.Position; + oldEndPos = selectedSlider.EndPosition; + oldDistance = selectedSlider.Path.Distance; + }); + + AddStep("Reverse slider", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }); + + AddAssert("Middle control point has the same distance from start to end", () => + { + var pathControlPoints = selectedSlider.Path.ControlPoints; + float middleToStart = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[0].Position); + float middleToEnd = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[^1].Position); + + return Precision.AlmostEquals(middleToStart, middleToEnd, 1f); + }); + + AddAssert("Middle control point is not at start or end", () => + Vector2.Distance(selectedSlider.Path.ControlPoints[^2].Position, oldStartPos) > 1 && + Vector2.Distance(selectedSlider.Path.ControlPoints[^2].Position, oldEndPos) > 1 + ); + + AddAssert("Slider has correct length", () => + Precision.AlmostEquals(selectedSlider.Path.Distance, oldDistance)); + + AddAssert("Slider has correct start position", () => + Vector2.Distance(selectedSlider.Position, oldEndPos) < 1); + + AddAssert("Slider has correct end position", () => + Vector2.Distance(selectedSlider.EndPosition, oldStartPos) < 1); + + AddAssert("Control points have correct types", () => + { + var newControlPointTypes = selectedSlider.Path.ControlPoints.Select(p => p.Type).ToArray(); + + return oldControlPointTypes.Take(newControlPointTypes.Length).SequenceEqual(newControlPointTypes); + }); + } + } + + [Test] + public void TestSegmentedSliderReversal() + { + PathControlPoint[] segmentedSliderPath = + [ + new PathControlPoint + { + Position = new Vector2(0, 0), + Type = PathType.PERFECT_CURVE + }, + new PathControlPoint + { + Position = new Vector2(100, 150), + }, + new PathControlPoint + { + Position = new Vector2(75, -50), + Type = PathType.PERFECT_CURVE + }, + new PathControlPoint + { + Position = new Vector2(225, -75), + }, + new PathControlPoint + { + Position = new Vector2(350, 50), + Type = PathType.PERFECT_CURVE + }, + new PathControlPoint + { + Position = new Vector2(500, -75), + }, + new PathControlPoint + { + Position = new Vector2(350, -120), + }, + ]; + + Vector2 oldStartPos = default; + Vector2 oldEndPos = default; + double oldDistance = default; + + var oldControlPointTypes = segmentedSliderPath.Select(p => p.Type); + + AddStep("Add slider", () => + { + var slider = new Slider + { + Position = new Vector2(0, 200), + Path = new SliderPath(segmentedSliderPath) + { + ExpectedDistance = { Value = 1314 } + } + }; + + EditorBeatmap.Add(slider); + + oldStartPos = slider.Position; + oldEndPos = slider.EndPosition; + oldDistance = slider.Path.Distance; + }); + + AddStep("Select slider", () => + { + var slider = (Slider)EditorBeatmap.HitObjects[0]; + EditorBeatmap.SelectedHitObjects.Add(slider); + }); + + AddRepeatStep("Reverse slider", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }, 3); + + AddAssert("First arc's control is not at the slider's middle", () => + Vector2.Distance(selectedSlider.Path.ControlPoints[^2].Position, selectedSlider.Path.PositionAt(0.5)) > 1 + ); + + AddAssert("Last arc's control is not at the slider's middle", () => + Vector2.Distance(selectedSlider.Path.ControlPoints[1].Position, selectedSlider.Path.PositionAt(0.5)) > 1 + ); + + AddAssert("First arc centered middle control point", () => + { + var pathControlPoints = selectedSlider.Path.ControlPoints; + float middleToStart = Vector2.Distance(pathControlPoints[1].Position, pathControlPoints[0].Position); + float middleToEnd = Vector2.Distance(pathControlPoints[1].Position, pathControlPoints[2].Position); + + return Precision.AlmostEquals(middleToStart, middleToEnd, 1f); + }); + + AddAssert("Last arc centered middle control point", () => + { + var pathControlPoints = selectedSlider.Path.ControlPoints; + float middleToStart = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[^3].Position); + float middleToEnd = Vector2.Distance(pathControlPoints[^2].Position, pathControlPoints[^1].Position); + + return Precision.AlmostEquals(middleToStart, middleToEnd, 1f); + }); + AddAssert("Slider has correct length", () => Precision.AlmostEquals(selectedSlider.Path.Distance, oldDistance)); diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs index 4ce8166421..b28225d81b 100644 --- a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs +++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects.Types; @@ -56,12 +55,12 @@ namespace osu.Game.Rulesets.Objects if (controlPoints.Count >= 3 && controlPoints[^3].Type == PathType.PERFECT_CURVE && controlPoints[^2].Type == null && segmentEnds.Any()) { double lastSegmentStart = segmentEnds.Length > 1 ? segmentEnds[^2] : 0; - double lastSegmentEnd = segmentEnds[^1]; - var circleArcPath = new List(); - sliderPath.GetPathToProgress(circleArcPath, lastSegmentStart / lastSegmentEnd, 1); - - controlPoints[^2].Position = circleArcPath[circleArcPath.Count / 2]; + // we want to shorten the last perfect segment, preserving its shape, so that its end is consistent with the slider path's end. + // therefore we also reposition the middle point of the segment to be ideally halfway through its arc. + // the end of the segment is assumed to be at path position 1 at all times, + // but the start of the segment cannot be assumed to be at 0 because multi-segment sliders exist. + controlPoints[^2].Position = sliderPath.PositionAt((lastSegmentStart + 1) / 2); } sliderPath.reverseControlPoints(out positionalOffset);