diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs index f196353f87..6d93c3fcf9 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs @@ -182,7 +182,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { AddStep($"click context menu item \"{contextMenuText}\"", () => { - MenuItem item = visualiser.ContextMenuItems[1].Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText); + MenuItem item = visualiser.ContextMenuItems.FirstOrDefault(menuItem => menuItem.Text.Value == "Curve type")?.Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText); item?.Action?.Value(); }); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs new file mode 100644 index 0000000000..015952c59a --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs @@ -0,0 +1,255 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneSliderSplitting : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private ComposeBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); + + private Slider? slider; + private PathControlPointVisualiser? visualiser; + + private const double split_gap = 100; + + [Test] + public void TestBasicSplit() + { + double endTime = 0; + + AddStep("add slider", () => + { + slider = new Slider + { + Position = new Vector2(0, 50), + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(150, 150)), + new PathControlPoint(new Vector2(300, 0), PathType.PerfectCurve), + new PathControlPoint(new Vector2(400, 0)), + new PathControlPoint(new Vector2(400, 150)) + }) + }; + + EditorBeatmap.Add(slider); + + endTime = slider.EndTime; + }); + + AddStep("select added slider", () => + { + EditorBeatmap.SelectedHitObjects.Add(slider); + visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType().First(); + }); + + moveMouseToControlPoint(2); + AddStep("select control point", () => + { + if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true; + }); + addContextMenuItemStep("Split control point"); + + AddAssert("slider split", () => slider is not null && EditorBeatmap.HitObjects.Count == 2 && + sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, EditorBeatmap.HitObjects[1].StartTime - split_gap, + (new Vector2(0, 50), PathType.PerfectCurve), + (new Vector2(150, 200), null), + (new Vector2(300, 50), null) + ) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[1], slider.StartTime, endTime + split_gap, + (new Vector2(300, 50), PathType.PerfectCurve), + (new Vector2(400, 50), null), + (new Vector2(400, 200), null) + )); + + AddStep("undo", () => Editor.Undo()); + AddAssert("original slider restored", () => EditorBeatmap.HitObjects.Count == 1 && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, endTime, + (new Vector2(0, 50), PathType.PerfectCurve), + (new Vector2(150, 200), null), + (new Vector2(300, 50), PathType.PerfectCurve), + (new Vector2(400, 50), null), + (new Vector2(400, 200), null) + )); + } + + [Test] + public void TestDoubleSplit() + { + double endTime = 0; + + AddStep("add slider", () => + { + slider = new Slider + { + Position = new Vector2(0, 50), + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(150, 150)), + new PathControlPoint(new Vector2(300, 0), PathType.Bezier), + new PathControlPoint(new Vector2(400, 0)), + new PathControlPoint(new Vector2(400, 150), PathType.Catmull), + new PathControlPoint(new Vector2(300, 200)), + new PathControlPoint(new Vector2(400, 250)) + }) + }; + + EditorBeatmap.Add(slider); + + endTime = slider.EndTime; + }); + + AddStep("select added slider", () => + { + EditorBeatmap.SelectedHitObjects.Add(slider); + visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType().First(); + }); + + moveMouseToControlPoint(2); + AddStep("select first control point", () => + { + if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true; + }); + moveMouseToControlPoint(4); + AddStep("select second control point", () => + { + if (visualiser is not null) visualiser.Pieces[4].IsSelected.Value = true; + }); + addContextMenuItemStep("Split 2 control points"); + + AddAssert("slider split", () => slider is not null && EditorBeatmap.HitObjects.Count == 3 && + sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, EditorBeatmap.HitObjects[1].StartTime - split_gap, + (new Vector2(0, 50), PathType.PerfectCurve), + (new Vector2(150, 200), null), + (new Vector2(300, 50), null) + ) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[1], EditorBeatmap.HitObjects[0].GetEndTime() + split_gap, slider.StartTime - split_gap, + (new Vector2(300, 50), PathType.Bezier), + (new Vector2(400, 50), null), + (new Vector2(400, 200), null) + ) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[2], EditorBeatmap.HitObjects[1].GetEndTime() + split_gap, endTime + split_gap * 2, + (new Vector2(400, 200), PathType.Catmull), + (new Vector2(300, 250), null), + (new Vector2(400, 300), null) + )); + } + + [Test] + public void TestSplitRetainsHitsounds() + { + HitSampleInfo? sample = null; + + AddStep("add slider", () => + { + slider = new Slider + { + Position = new Vector2(0, 50), + LegacyLastTickOffset = 36, // This is necessary for undo to retain the sample control point + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(150, 150)), + new PathControlPoint(new Vector2(300, 0), PathType.PerfectCurve), + new PathControlPoint(new Vector2(400, 0)), + new PathControlPoint(new Vector2(400, 150)) + }) + }; + + EditorBeatmap.Add(slider); + }); + + AddStep("add hitsounds", () => + { + if (slider is null) return; + + slider.SampleControlPoint.SampleBank = "soft"; + slider.SampleControlPoint.SampleVolume = 70; + sample = new HitSampleInfo("hitwhistle"); + slider.Samples.Add(sample); + }); + + AddStep("select added slider", () => + { + EditorBeatmap.SelectedHitObjects.Add(slider); + visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType().First(); + }); + + moveMouseToControlPoint(2); + AddStep("select control point", () => + { + if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true; + }); + addContextMenuItemStep("Split control point"); + AddAssert("sliders have hitsounds", hasHitsounds); + + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0])); + AddStep("remove first slider", () => EditorBeatmap.RemoveAt(0)); + AddStep("undo", () => Editor.Undo()); + AddAssert("sliders have hitsounds", hasHitsounds); + + bool hasHitsounds() => sample is not null && + EditorBeatmap.HitObjects.All(o => o.SampleControlPoint.SampleBank == "soft" && + o.SampleControlPoint.SampleVolume == 70 && + o.Samples.Contains(sample)); + } + + private bool sliderCreatedFor(Slider s, double startTime, double endTime, params (Vector2 pos, PathType? pathType)[] expectedControlPoints) + { + if (!Precision.AlmostEquals(s.StartTime, startTime, 1) || !Precision.AlmostEquals(s.EndTime, endTime, 1)) return false; + + int i = 0; + + foreach ((Vector2 pos, PathType? pathType) in expectedControlPoints) + { + var controlPoint = s.Path.ControlPoints[i++]; + + if (!Precision.AlmostEquals(controlPoint.Position + s.Position, pos) || controlPoint.Type != pathType) + return false; + } + + return true; + } + + private void moveMouseToControlPoint(int index) + { + AddStep($"move mouse to control point {index}", () => + { + if (slider is null || visualiser is null) return; + + Vector2 position = slider.Path.ControlPoints[index].Position + slider.Position; + InputManager.MoveMouseTo(visualiser.Pieces[0].Parent.ToScreenSpace(position)); + }); + } + + private void addContextMenuItemStep(string contextMenuText) + { + AddStep($"click context menu item \"{contextMenuText}\"", () => + { + if (visualiser is null) return; + + MenuItem? item = visualiser.ContextMenuItems.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText); + + item?.Action?.Value(); + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index c1908ef44c..c24f78e430 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private InputManager inputManager; public Action> RemoveControlPointsRequested; + public Action> SplitControlPointsRequested; [Resolved(CanBeNull = true)] private IDistanceSnapProvider snapProvider { get; set; } @@ -104,6 +105,29 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components return true; } + private bool splitSelected() + { + List controlPointsToSplitAt = Pieces.Where(p => p.IsSelected.Value && isSplittable(p)).Select(p => p.ControlPoint).ToList(); + + // Ensure that there are any points to be split + if (controlPointsToSplitAt.Count == 0) + return false; + + changeHandler?.BeginChange(); + SplitControlPointsRequested?.Invoke(controlPointsToSplitAt); + changeHandler?.EndChange(); + + // Since pieces are re-used, they will not point to the deleted control points while remaining selected + foreach (var piece in Pieces) + piece.IsSelected.Value = false; + + return true; + } + + private bool isSplittable(PathControlPointPiece p) => + // A slider can only be split on control points which connect two different slider segments. + p.ControlPoint.Type.HasValue && p != Pieces.FirstOrDefault() && p != Pieces.LastOrDefault(); + private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) @@ -324,25 +348,42 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (count == 0) return null; - List items = new List(); + var splittablePieces = selectedPieces.Where(isSplittable).ToList(); + int splittableCount = splittablePieces.Count; + + List curveTypeItems = new List(); if (!selectedPieces.Contains(Pieces[0])) - items.Add(createMenuItemForPathType(null)); + curveTypeItems.Add(createMenuItemForPathType(null)); // todo: hide/disable items which aren't valid for selected points - items.Add(createMenuItemForPathType(PathType.Linear)); - items.Add(createMenuItemForPathType(PathType.PerfectCurve)); - items.Add(createMenuItemForPathType(PathType.Bezier)); - items.Add(createMenuItemForPathType(PathType.Catmull)); + curveTypeItems.Add(createMenuItemForPathType(PathType.Linear)); + curveTypeItems.Add(createMenuItemForPathType(PathType.PerfectCurve)); + curveTypeItems.Add(createMenuItemForPathType(PathType.Bezier)); + curveTypeItems.Add(createMenuItemForPathType(PathType.Catmull)); - return new MenuItem[] + var menuItems = new List { - new OsuMenuItem($"Delete {"control point".ToQuantity(count, count > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}", MenuItemType.Destructive, () => DeleteSelected()), new OsuMenuItem("Curve type") { - Items = items + Items = curveTypeItems } }; + + if (splittableCount > 0) + { + menuItems.Add(new OsuMenuItem($"Split {"control point".ToQuantity(splittableCount, splittableCount > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}", + MenuItemType.Destructive, + () => splitSelected())); + } + + menuItems.Add( + new OsuMenuItem($"Delete {"control point".ToQuantity(count, count > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}", + MenuItemType.Destructive, + () => DeleteSelected()) + ); + + return menuItems.ToArray(); } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 794551dab7..eb69efd636 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; @@ -111,7 +112,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(HitObject, true) { - RemoveControlPointsRequested = removeControlPoints + RemoveControlPointsRequested = removeControlPoints, + SplitControlPointsRequested = splitControlPoints }); base.OnSelected(); @@ -249,6 +251,74 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders HitObject.Position += first; } + private void splitControlPoints(List controlPointsToSplitAt) + { + // Arbitrary gap in milliseconds to put between split slider pieces + const double split_gap = 100; + + // Ensure that there are any points to be split + if (controlPointsToSplitAt.Count == 0) + return; + + editorBeatmap.SelectedHitObjects.Clear(); + + foreach (var splitPoint in controlPointsToSplitAt) + { + if (splitPoint == controlPoints[0] || splitPoint == controlPoints[^1] || splitPoint.Type is null) + continue; + + // Split off the section of slider before this control point so the remaining control points to split are in the latter part of the slider. + int index = controlPoints.IndexOf(splitPoint); + + if (index <= 0) + continue; + + // Extract the split portion and remove from the original slider. + var splitControlPoints = controlPoints.Take(index + 1).ToList(); + controlPoints.RemoveRange(0, index); + + // Turn the control points which were split off into a new slider. + var samplePoint = (SampleControlPoint)HitObject.SampleControlPoint.DeepClone(); + var difficultyPoint = (DifficultyControlPoint)HitObject.DifficultyControlPoint.DeepClone(); + + var newSlider = new Slider + { + StartTime = HitObject.StartTime, + Position = HitObject.Position + splitControlPoints[0].Position, + NewCombo = HitObject.NewCombo, + SampleControlPoint = samplePoint, + DifficultyControlPoint = difficultyPoint, + LegacyLastTickOffset = HitObject.LegacyLastTickOffset, + Samples = HitObject.Samples.Select(s => s.With()).ToList(), + RepeatCount = HitObject.RepeatCount, + NodeSamples = HitObject.NodeSamples.Select(n => (IList)n.Select(s => s.With()).ToList()).ToList(), + Path = new SliderPath(splitControlPoints.Select(o => new PathControlPoint(o.Position - splitControlPoints[0].Position, o == splitControlPoints[^1] ? null : o.Type)).ToArray()) + }; + + // Increase the start time of the slider before adding the new slider so the new slider is immediately inserted at the correct index and internal state remains valid. + HitObject.StartTime += split_gap; + + editorBeatmap.Add(newSlider); + + HitObject.NewCombo = false; + HitObject.Path.ExpectedDistance.Value -= newSlider.Path.CalculatedDistance; + HitObject.StartTime += newSlider.SpanDuration; + + // In case the remainder of the slider has no length left over, give it length anyways so we don't get a 0 length slider. + if (HitObject.Path.ExpectedDistance.Value <= Precision.DOUBLE_EPSILON) + { + HitObject.Path.ExpectedDistance.Value = null; + } + } + + // Once all required pieces have been split off, the original slider has the final split. + // As a final step, we must reset its control points to have an origin of (0,0). + Vector2 first = controlPoints[0].Position; + foreach (var c in controlPoints) + c.Position -= first; + HitObject.Position += first; + } + private void convertToStream() { if (editorBeatmap == null || beatDivisor == null)