diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchReverseSelection.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchReverseSelection.cs new file mode 100644 index 0000000000..36a0e3388e --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchReverseSelection.cs @@ -0,0 +1,253 @@ +// 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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + [TestFixture] + public partial class TestSceneCatchReverseSelection : TestSceneEditor + { + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + [Test] + public void TestReverseSelectionTwoFruits() + { + CatchHitObject[] objects = null!; + bool[] newCombos = null!; + + addObjects([ + new Fruit + { + StartTime = 200, + X = 0, + }, + new Fruit + { + StartTime = 400, + X = 20, + } + ]); + + AddStep("store objects & new combo data", () => + { + objects = getObjects().ToArray(); + newCombos = getObjectNewCombos().ToArray(); + }); + + selectEverything(); + reverseSelection(); + + AddAssert("objects reversed", getObjects, () => Is.EqualTo(objects.Reverse())); + AddAssert("new combo positions preserved", getObjectNewCombos, () => Is.EqualTo(newCombos)); + } + + [Test] + public void TestReverseSelectionThreeFruits() + { + CatchHitObject[] objects = null!; + bool[] newCombos = null!; + + addObjects([ + new Fruit + { + StartTime = 200, + X = 0, + }, + new Fruit + { + StartTime = 400, + X = 20, + }, + new Fruit + { + StartTime = 600, + X = 40, + } + ]); + + AddStep("store objects & new combo data", () => + { + objects = getObjects().ToArray(); + newCombos = getObjectNewCombos().ToArray(); + }); + + selectEverything(); + reverseSelection(); + + AddAssert("objects reversed", getObjects, () => Is.EqualTo(objects.Reverse())); + AddAssert("new combo positions preserved", getObjectNewCombos, () => Is.EqualTo(newCombos)); + } + + [Test] + public void TestReverseSelectionFruitAndJuiceStream() + { + CatchHitObject[] objects = null!; + bool[] newCombos = null!; + + addObjects([ + new Fruit + { + StartTime = 200, + X = 0, + }, + new JuiceStream + { + StartTime = 400, + X = 20, + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(50)) + } + } + } + ]); + + AddStep("store objects & new combo data", () => + { + objects = getObjects().ToArray(); + newCombos = getObjectNewCombos().ToArray(); + }); + + selectEverything(); + reverseSelection(); + + AddAssert("objects reversed", getObjects, () => Is.EqualTo(objects.Reverse())); + AddAssert("new combo positions preserved", getObjectNewCombos, () => Is.EqualTo(newCombos)); + } + + [Test] + public void TestReverseSelectionTwoFruitsAndJuiceStream() + { + CatchHitObject[] objects = null!; + bool[] newCombos = null!; + + addObjects([ + new Fruit + { + StartTime = 200, + X = 0, + }, + new Fruit + { + StartTime = 400, + X = 20, + }, + new JuiceStream + { + StartTime = 600, + X = 40, + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(50)) + } + } + } + ]); + + AddStep("store objects & new combo data", () => + { + objects = getObjects().ToArray(); + newCombos = getObjectNewCombos().ToArray(); + }); + + selectEverything(); + reverseSelection(); + + AddAssert("objects reversed", getObjects, () => Is.EqualTo(objects.Reverse())); + AddAssert("new combo positions preserved", getObjectNewCombos, () => Is.EqualTo(newCombos)); + } + + [Test] + public void TestReverseSelectionTwoCombos() + { + CatchHitObject[] objects = null!; + bool[] newCombos = null!; + + addObjects([ + new Fruit + { + StartTime = 200, + X = 0, + }, + new Fruit + { + StartTime = 400, + X = 20, + }, + new Fruit + { + StartTime = 600, + X = 40, + }, + + new Fruit + { + StartTime = 800, + NewCombo = true, + X = 60, + }, + new Fruit + { + StartTime = 1000, + X = 80, + }, + new Fruit + { + StartTime = 1200, + X = 100, + } + ]); + + AddStep("store objects & new combo data", () => + { + objects = getObjects().ToArray(); + newCombos = getObjectNewCombos().ToArray(); + }); + + selectEverything(); + reverseSelection(); + + AddAssert("objects reversed", getObjects, () => Is.EqualTo(objects.Reverse())); + AddAssert("new combo positions preserved", getObjectNewCombos, () => Is.EqualTo(newCombos)); + } + + private void addObjects(CatchHitObject[] hitObjects) => AddStep("Add objects", () => EditorBeatmap.AddRange(hitObjects)); + + private IEnumerable getObjects() => EditorBeatmap.HitObjects.OfType(); + + private IEnumerable getObjectNewCombos() => getObjects().Select(ho => ho.NewCombo); + + private void selectEverything() + { + AddStep("Select everything", () => + { + EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects); + }); + } + + private void reverseSelection() + { + AddStep("Reverse selection", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs index 418351e2f3..a2784126eb 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs @@ -76,21 +76,38 @@ namespace osu.Game.Rulesets.Catch.Edit public override bool HandleReverse() { + var hitObjects = EditorBeatmap.SelectedHitObjects + .OfType() + .OrderBy(obj => obj.StartTime) + .ToList(); + double selectionStartTime = SelectedItems.Min(h => h.StartTime); double selectionEndTime = SelectedItems.Max(h => h.GetEndTime()); - EditorBeatmap.PerformOnSelection(hitObject => - { - hitObject.StartTime = selectionEndTime - (hitObject.GetEndTime() - selectionStartTime); + // the expectation is that even if the objects themselves are reversed temporally, + // the position of new combos in the selection should remain the same. + // preserve it for later before doing the reversal. + var newComboOrder = hitObjects.Select(obj => obj.NewCombo).ToList(); - if (hitObject is JuiceStream juiceStream) + foreach (var h in hitObjects) + { + h.StartTime = selectionEndTime - (h.GetEndTime() - selectionStartTime); + + if (h is JuiceStream juiceStream) { juiceStream.Path.Reverse(out Vector2 positionalOffset); juiceStream.OriginalX += positionalOffset.X; juiceStream.LegacyConvertedY += positionalOffset.Y; EditorBeatmap.Update(juiceStream); } - }); + } + + // re-order objects by start time again after reversing, and restore new combo flag positioning + hitObjects = hitObjects.OrderBy(obj => obj.StartTime).ToList(); + + for (int i = 0; i < hitObjects.Count; ++i) + hitObjects[i].NewCombo = newComboOrder[i]; + return true; } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuReverseSelection.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuReverseSelection.cs new file mode 100644 index 0000000000..28c1577fcb --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuReverseSelection.cs @@ -0,0 +1,300 @@ +// 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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + [TestFixture] + public partial class TestSceneOsuReverseSelection : TestSceneOsuEditor + { + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + [Test] + public void TestReverseSelectionTwoCircles() + { + OsuHitObject[] objects = null!; + bool[] newCombos = null!; + + AddStep("Add circles", () => + { + var circle1 = new HitCircle + { + StartTime = 0, + Position = new Vector2(208, 240) + }; + var circle2 = new HitCircle + { + StartTime = 200, + Position = new Vector2(256, 144) + }; + + EditorBeatmap.AddRange([circle1, circle2]); + }); + + AddStep("store objects & new combo data", () => + { + objects = getObjects().ToArray(); + newCombos = getObjectNewCombos().ToArray(); + }); + + AddStep("Select circles", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + AddStep("Reverse selection", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }); + + AddAssert("objects reversed", getObjects, () => Is.EqualTo(objects.Reverse())); + AddAssert("new combo positions preserved", getObjectNewCombos, () => Is.EqualTo(newCombos)); + } + + [Test] + public void TestReverseSelectionThreeCircles() + { + OsuHitObject[] objects = null!; + bool[] newCombos = null!; + + AddStep("Add circles", () => + { + var circle1 = new HitCircle + { + StartTime = 0, + Position = new Vector2(208, 240) + }; + var circle2 = new HitCircle + { + StartTime = 200, + Position = new Vector2(256, 144) + }; + var circle3 = new HitCircle + { + StartTime = 400, + Position = new Vector2(304, 240) + }; + + EditorBeatmap.AddRange([circle1, circle2, circle3]); + }); + + AddStep("store objects & new combo data", () => + { + objects = getObjects().ToArray(); + newCombos = getObjectNewCombos().ToArray(); + }); + + AddStep("Select circles", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + AddStep("Reverse selection", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }); + + AddAssert("objects reversed", getObjects, () => Is.EqualTo(objects.Reverse())); + AddAssert("new combo positions preserved", getObjectNewCombos, () => Is.EqualTo(newCombos)); + } + + [Test] + public void TestReverseSelectionCircleAndSlider() + { + OsuHitObject[] objects = null!; + bool[] newCombos = null!; + + Vector2 sliderHeadOldPosition = default; + Vector2 sliderTailOldPosition = default; + + AddStep("Add objects", () => + { + var circle = new HitCircle + { + StartTime = 0, + Position = new Vector2(208, 240) + }; + var slider = new Slider + { + StartTime = 200, + Position = sliderHeadOldPosition = new Vector2(257, 144), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(100)) + } + } + }; + + sliderTailOldPosition = slider.EndPosition; + + EditorBeatmap.AddRange([circle, slider]); + }); + + AddStep("store objects & new combo data", () => + { + objects = getObjects().ToArray(); + newCombos = getObjectNewCombos().ToArray(); + }); + + AddStep("Select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + AddStep("Reverse selection", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }); + + AddAssert("objects reversed", getObjects, () => Is.EqualTo(objects.Reverse())); + AddAssert("new combo positions preserved", getObjectNewCombos, () => Is.EqualTo(newCombos)); + + AddAssert("Slider head is at slider tail", () => + Vector2.Distance(EditorBeatmap.HitObjects.OfType().ElementAt(0).Position, sliderTailOldPosition) < 1); + + AddAssert("Slider tail is at slider head", () => + Vector2.Distance(EditorBeatmap.HitObjects.OfType().ElementAt(0).EndPosition, sliderHeadOldPosition) < 1); + } + + [Test] + public void TestReverseSelectionTwoCirclesAndSlider() + { + OsuHitObject[] objects = null!; + bool[] newCombos = null!; + + Vector2 sliderHeadOldPosition = default; + Vector2 sliderTailOldPosition = default; + + AddStep("Add objects", () => + { + var circle1 = new HitCircle + { + StartTime = 0, + Position = new Vector2(208, 240) + }; + var circle2 = new HitCircle + { + StartTime = 200, + Position = new Vector2(256, 144) + }; + var slider = new Slider + { + StartTime = 200, + Position = sliderHeadOldPosition = new Vector2(304, 240), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(100)) + } + } + }; + + sliderTailOldPosition = slider.EndPosition; + + EditorBeatmap.AddRange([circle1, circle2, slider]); + }); + + AddStep("store objects & new combo data", () => + { + objects = getObjects().ToArray(); + newCombos = getObjectNewCombos().ToArray(); + }); + + AddStep("Select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + AddStep("Reverse selection", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }); + + AddAssert("objects reversed", getObjects, () => Is.EqualTo(objects.Reverse())); + AddAssert("new combo positions preserved", getObjectNewCombos, () => Is.EqualTo(newCombos)); + + AddAssert("Slider head is at slider tail", () => + Vector2.Distance(EditorBeatmap.HitObjects.OfType().ElementAt(0).Position, sliderTailOldPosition) < 1); + + AddAssert("Slider tail is at slider head", () => + Vector2.Distance(EditorBeatmap.HitObjects.OfType().ElementAt(0).EndPosition, sliderHeadOldPosition) < 1); + } + + [Test] + public void TestReverseSelectionTwoCombos() + { + OsuHitObject[] objects = null!; + bool[] newCombos = null!; + + AddStep("Add circles", () => + { + var circle1 = new HitCircle + { + StartTime = 0, + Position = new Vector2(216, 240) + }; + var circle2 = new HitCircle + { + StartTime = 200, + Position = new Vector2(120, 192) + }; + var circle3 = new HitCircle + { + StartTime = 400, + Position = new Vector2(216, 144) + }; + + var circle4 = new HitCircle + { + StartTime = 646, + NewCombo = true, + Position = new Vector2(296, 240) + }; + var circle5 = new HitCircle + { + StartTime = 846, + Position = new Vector2(392, 162) + }; + var circle6 = new HitCircle + { + StartTime = 1046, + Position = new Vector2(296, 144) + }; + + EditorBeatmap.AddRange([circle1, circle2, circle3, circle4, circle5, circle6]); + }); + + AddStep("store objects & new combo data", () => + { + objects = getObjects().ToArray(); + newCombos = getObjectNewCombos().ToArray(); + }); + + AddStep("Select circles", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + AddStep("Reverse selection", () => + { + InputManager.PressKey(Key.LControl); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.LControl); + }); + + AddAssert("objects reversed", getObjects, () => Is.EqualTo(objects.Reverse())); + AddAssert("new combo positions preserved", getObjectNewCombos, () => Is.EqualTo(newCombos)); + } + + private IEnumerable getObjects() => EditorBeatmap.HitObjects.OfType(); + + private IEnumerable getObjectNewCombos() => getObjects().Select(ho => ho.NewCombo); + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index cea2adc6e2..b33272968b 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -78,13 +78,21 @@ namespace osu.Game.Rulesets.Osu.Edit public override bool HandleReverse() { - var hitObjects = EditorBeatmap.SelectedHitObjects; + var hitObjects = EditorBeatmap.SelectedHitObjects + .OfType() + .OrderBy(obj => obj.StartTime) + .ToList(); double endTime = hitObjects.Max(h => h.GetEndTime()); double startTime = hitObjects.Min(h => h.StartTime); bool moreThanOneObject = hitObjects.Count > 1; + // the expectation is that even if the objects themselves are reversed temporally, + // the position of new combos in the selection should remain the same. + // preserve it for later before doing the reversal. + var newComboOrder = hitObjects.Select(obj => obj.NewCombo).ToList(); + foreach (var h in hitObjects) { if (moreThanOneObject) @@ -97,6 +105,12 @@ namespace osu.Game.Rulesets.Osu.Edit } } + // re-order objects by start time again after reversing, and restore new combo flag positioning + hitObjects = hitObjects.OrderBy(obj => obj.StartTime).ToList(); + + for (int i = 0; i < hitObjects.Count; ++i) + hitObjects[i].NewCombo = newComboOrder[i]; + return true; }