From 27707d52ec3815d66cdcbb4ebcabca90caf4453c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 1 Nov 2021 19:37:29 +0100 Subject: [PATCH 1/4] Implement slider-to-stream conversion --- .../Sliders/SliderSelectionBlueprint.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index a7fadfb67f..ecf134dd15 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -47,6 +48,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders [Resolved(CanBeNull = true)] private IEditorChangeHandler changeHandler { get; set; } + [Resolved(CanBeNull = true)] + private BindableBeatDivisor beatDivisor { get; set; } + public override Quad SelectionQuad => BodyPiece.ScreenSpaceDrawQuad; private readonly BindableList controlPoints = new BindableList(); @@ -173,6 +177,20 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } } + protected override bool OnKeyDown(KeyDownEvent e) + { + if (!IsSelected) + return false; + + if (e.Key == Key.F && e.ControlPressed && e.ShiftPressed) + { + convertToStream(); + return true; + } + + return false; + } + private int addControlPoint(Vector2 position) { position -= HitObject.Position; @@ -234,9 +252,42 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders editorBeatmap?.Update(HitObject); } + private void convertToStream() + { + if (editorBeatmap == null || changeHandler == null || beatDivisor == null) + return; + + var timingPoint = editorBeatmap.ControlPointInfo.TimingPointAt(HitObject.StartTime); + double streamSpacing = timingPoint.BeatLength / beatDivisor.Value; + + changeHandler.BeginChange(); + + int i = 0; + double time = HitObject.StartTime; + + while (!Precision.DefinitelyBigger(time, HitObject.GetEndTime(), 1)) + { + Vector2 position = HitObject.Position + HitObject.Path.PositionAt((time - HitObject.StartTime) / HitObject.Duration); + editorBeatmap.Add(new HitCircle + { + StartTime = time, + Position = position, + NewCombo = i == 0 && HitObject.NewCombo + }); + + i += 1; + time = HitObject.StartTime + i * streamSpacing; + } + + editorBeatmap.Remove(HitObject); + + changeHandler.EndChange(); + } + public override MenuItem[] ContextMenuItems => new MenuItem[] { new OsuMenuItem("Add control point", MenuItemType.Standard, () => addControlPoint(rightClickPosition)), + new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream), }; // Always refer to the drawable object's slider body so subsequent movement deltas are calculated with updated positions. From d9494d405ef5b12000a8a1a4a412ef0c21af3545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 1 Nov 2021 19:37:37 +0100 Subject: [PATCH 2/4] Add test coverage for slider-to-stream conversion --- .../Editor/TestSceneSliderStreamConversion.cs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs new file mode 100644 index 0000000000..f8cbd046d1 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs @@ -0,0 +1,140 @@ +// 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.Utils; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneSliderStreamConversion : TestSceneOsuEditor + { + private BindableBeatDivisor beatDivisor => (BindableBeatDivisor)Editor.Dependencies.Get(typeof(BindableBeatDivisor)); + + [Test] + public void TestSimpleConversion() + { + Slider slider = null; + + AddStep("select first slider", () => + { + slider = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider); + EditorClock.Seek(slider.StartTime); + EditorBeatmap.SelectedHitObjects.Add(slider); + }); + + convertToStream(); + + AddAssert("stream created", () => streamCreatedFor(slider, 1 / 4d)); + + AddStep("undo", () => Editor.Undo()); + AddAssert("slider restored", () => sliderRestored(slider)); + + AddStep("select first slider", () => + { + slider = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider); + EditorClock.Seek(slider.StartTime); + EditorBeatmap.SelectedHitObjects.Add(slider); + }); + AddStep("change beat divisor", () => beatDivisor.Value = 8); + + convertToStream(); + AddAssert("stream created", () => streamCreatedFor(slider, 1 / 8d)); + } + + [Test] + public void TestConversionWithNonMatchingDivisor() + { + Slider slider = null; + + AddStep("select second slider", () => + { + slider = (Slider)EditorBeatmap.HitObjects.Where(h => h is Slider).ElementAt(1); + EditorClock.Seek(slider.StartTime); + EditorBeatmap.SelectedHitObjects.Add(slider); + }); + AddStep("change beat divisor", () => beatDivisor.Value = 3); + + convertToStream(); + + AddAssert("stream created", () => streamCreatedFor(slider, 2 / 3d)); + } + + [Test] + public void TestConversionPreservesNewCombo() + { + Slider slider = null; + + AddStep("select second new-combo-starting slider", () => + { + slider = (Slider)EditorBeatmap.HitObjects.Where(h => h is Slider s && s.NewCombo).ElementAt(1); + EditorClock.Seek(slider.StartTime); + EditorBeatmap.SelectedHitObjects.Add(slider); + }); + + convertToStream(); + + AddAssert("stream created", () => streamCreatedFor(slider, 1 / 4d)); + + AddStep("undo", () => Editor.Undo()); + AddAssert("slider restored", () => sliderRestored(slider)); + } + + private void convertToStream() + { + AddStep("convert to stream", () => + { + InputManager.PressKey(Key.LControl); + InputManager.PressKey(Key.LShift); + InputManager.Key(Key.F); + InputManager.ReleaseKey(Key.LShift); + InputManager.ReleaseKey(Key.LControl); + }); + } + + private bool streamCreatedFor(Slider slider, double spacing) + { + if (EditorBeatmap.HitObjects.Contains(slider)) + return false; + + for (int i = 0; i * spacing <= 1; ++i) + { + double progress = i * spacing; + double time = slider.StartTime + progress * slider.Duration; + Vector2 position = slider.Position + slider.Path.PositionAt(progress); + + if (!EditorBeatmap.HitObjects.OfType().Any(h => matches(h, time, position, slider.NewCombo && progress == 0))) + return false; + } + + return true; + + bool matches(HitCircle circle, double time, Vector2 position, bool startsNewCombo) => + Precision.AlmostEquals(circle.StartTime, time, 1) + && Precision.AlmostEquals(circle.Position, position, 0.01f) + && circle.NewCombo == startsNewCombo; + } + + private bool sliderRestored(Slider slider) + { + var objects = EditorBeatmap.HitObjects.Where(h => h.StartTime >= slider.StartTime && h.GetEndTime() <= slider.EndTime).ToList(); + + if (objects.Count > 1) + return false; + + var hitObject = objects.Single(); + if (!(hitObject is Slider restoredSlider)) + return false; + + return Precision.AlmostEquals(slider.StartTime, restoredSlider.StartTime) + && Precision.AlmostEquals(slider.GetEndTime(), restoredSlider.GetEndTime()) + && Precision.AlmostEquals(slider.Position, restoredSlider.Position, 0.01f) + && Precision.AlmostEquals(slider.EndPosition, restoredSlider.EndPosition, 0.01f); + } + } +} From 0cd3f9859817f354076d766745463ab7e1d73436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 11 Nov 2021 21:43:06 +0100 Subject: [PATCH 3/4] Ensure samples & sample points are carried over during conversion --- .../Editor/TestSceneSliderStreamConversion.cs | 6 ++++-- .../Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs | 9 ++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs index f8cbd046d1..11ab05c84e 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs @@ -66,7 +66,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor } [Test] - public void TestConversionPreservesNewCombo() + public void TestConversionPreservesSliderProperties() { Slider slider = null; @@ -117,7 +117,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor bool matches(HitCircle circle, double time, Vector2 position, bool startsNewCombo) => Precision.AlmostEquals(circle.StartTime, time, 1) && Precision.AlmostEquals(circle.Position, position, 0.01f) - && circle.NewCombo == startsNewCombo; + && circle.NewCombo == startsNewCombo + && circle.Samples.SequenceEqual(slider.HeadCircle.Samples) + && circle.SampleControlPoint.IsRedundant(slider.SampleControlPoint); } private bool sliderRestored(Slider slider) diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index ecf134dd15..d646d90d99 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -268,11 +269,17 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders while (!Precision.DefinitelyBigger(time, HitObject.GetEndTime(), 1)) { Vector2 position = HitObject.Position + HitObject.Path.PositionAt((time - HitObject.StartTime) / HitObject.Duration); + + var samplePoint = (SampleControlPoint)HitObject.SampleControlPoint.DeepClone(); + samplePoint.Time = time; + editorBeatmap.Add(new HitCircle { StartTime = time, Position = position, - NewCombo = i == 0 && HitObject.NewCombo + NewCombo = i == 0 && HitObject.NewCombo, + SampleControlPoint = samplePoint, + Samples = HitObject.HeadCircle.Samples.Select(s => s.With()).ToList() }); i += 1; From 8aa04864cef399d3408202bba6ddc9b202da0aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 11 Nov 2021 22:20:16 +0100 Subject: [PATCH 4/4] Add support for converting sliders with repeats to streams --- .../Editor/TestSceneSliderStreamConversion.cs | 63 ++++++++++++++++--- .../Sliders/SliderSelectionBlueprint.cs | 11 +++- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs index 11ab05c84e..559d612037 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs @@ -30,7 +30,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor convertToStream(); - AddAssert("stream created", () => streamCreatedFor(slider, 1 / 4d)); + AddAssert("stream created", () => streamCreatedFor(slider, + (time: 0, pathPosition: 0), + (time: 0.25, pathPosition: 0.25), + (time: 0.5, pathPosition: 0.5), + (time: 0.75, pathPosition: 0.75), + (time: 1, pathPosition: 1))); AddStep("undo", () => Editor.Undo()); AddAssert("slider restored", () => sliderRestored(slider)); @@ -44,7 +49,16 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddStep("change beat divisor", () => beatDivisor.Value = 8); convertToStream(); - AddAssert("stream created", () => streamCreatedFor(slider, 1 / 8d)); + AddAssert("stream created", () => streamCreatedFor(slider, + (time: 0, pathPosition: 0), + (time: 0.125, pathPosition: 0.125), + (time: 0.25, pathPosition: 0.25), + (time: 0.375, pathPosition: 0.375), + (time: 0.5, pathPosition: 0.5), + (time: 0.625, pathPosition: 0.625), + (time: 0.75, pathPosition: 0.75), + (time: 0.875, pathPosition: 0.875), + (time: 1, pathPosition: 1))); } [Test] @@ -62,7 +76,32 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor convertToStream(); - AddAssert("stream created", () => streamCreatedFor(slider, 2 / 3d)); + AddAssert("stream created", () => streamCreatedFor(slider, + (time: 0, pathPosition: 0), + (time: 2 / 3d, pathPosition: 2 / 3d))); + } + + [Test] + public void TestConversionWithRepeats() + { + Slider slider = null; + + AddStep("select first slider with repeats", () => + { + slider = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider s && s.RepeatCount > 0); + EditorClock.Seek(slider.StartTime); + EditorBeatmap.SelectedHitObjects.Add(slider); + }); + AddStep("change beat divisor", () => beatDivisor.Value = 2); + + convertToStream(); + + AddAssert("stream created", () => streamCreatedFor(slider, + (time: 0, pathPosition: 0), + (time: 0.25, pathPosition: 0.5), + (time: 0.5, pathPosition: 1), + (time: 0.75, pathPosition: 0.5), + (time: 1, pathPosition: 0))); } [Test] @@ -79,7 +118,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor convertToStream(); - AddAssert("stream created", () => streamCreatedFor(slider, 1 / 4d)); + AddAssert("stream created", () => streamCreatedFor(slider, + (time: 0, pathPosition: 0), + (time: 0.25, pathPosition: 0.25), + (time: 0.5, pathPosition: 0.5), + (time: 0.75, pathPosition: 0.75), + (time: 1, pathPosition: 1))); AddStep("undo", () => Editor.Undo()); AddAssert("slider restored", () => sliderRestored(slider)); @@ -97,18 +141,17 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor }); } - private bool streamCreatedFor(Slider slider, double spacing) + private bool streamCreatedFor(Slider slider, params (double time, double pathPosition)[] expectedCircles) { if (EditorBeatmap.HitObjects.Contains(slider)) return false; - for (int i = 0; i * spacing <= 1; ++i) + foreach ((double expectedTime, double expectedPathPosition) in expectedCircles) { - double progress = i * spacing; - double time = slider.StartTime + progress * slider.Duration; - Vector2 position = slider.Position + slider.Path.PositionAt(progress); + double time = slider.StartTime + slider.Duration * expectedTime; + Vector2 position = slider.Position + slider.Path.PositionAt(expectedPathPosition); - if (!EditorBeatmap.HitObjects.OfType().Any(h => matches(h, time, position, slider.NewCombo && progress == 0))) + if (!EditorBeatmap.HitObjects.OfType().Any(h => matches(h, time, position, slider.NewCombo && expectedTime == 0))) return false; } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index d646d90d99..17a62fc61c 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -16,6 +16,7 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; 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.Rulesets.Osu.Objects.Drawables; @@ -268,7 +269,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders while (!Precision.DefinitelyBigger(time, HitObject.GetEndTime(), 1)) { - Vector2 position = HitObject.Position + HitObject.Path.PositionAt((time - HitObject.StartTime) / HitObject.Duration); + // positionWithRepeats is a fractional number in the range of [0, HitObject.SpanCount()] + // and indicates how many fractional spans of a slider have passed up to time. + double positionWithRepeats = (time - HitObject.StartTime) / HitObject.Duration * HitObject.SpanCount(); + double pathPosition = positionWithRepeats - (int)positionWithRepeats; + // every second span is in the reverse direction - need to reverse the path position. + if (Precision.AlmostBigger(positionWithRepeats % 2, 1)) + pathPosition = 1 - pathPosition; + + Vector2 position = HitObject.Position + HitObject.Path.PositionAt(pathPosition); var samplePoint = (SampleControlPoint)HitObject.SampleControlPoint.DeepClone(); samplePoint.Time = time;