mirror of
https://github.com/ppy/osu.git
synced 2025-01-12 22:33:05 +08:00
Merge pull request #19782 from OliBomby/slider-merger
Add ability to merge hit objects in osu! editor to create sliders
This commit is contained in:
commit
c3b3733307
213
osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs
Normal file
213
osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Objects.Types;
|
||||||
|
using osu.Game.Rulesets.Osu.Objects;
|
||||||
|
using osuTK;
|
||||||
|
using osuTK.Input;
|
||||||
|
|
||||||
|
namespace osu.Game.Rulesets.Osu.Tests.Editor
|
||||||
|
{
|
||||||
|
public class TestSceneObjectMerging : TestSceneOsuEditor
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void TestSimpleMerge()
|
||||||
|
{
|
||||||
|
HitCircle? circle1 = null;
|
||||||
|
HitCircle? circle2 = null;
|
||||||
|
|
||||||
|
AddStep("select first two circles", () =>
|
||||||
|
{
|
||||||
|
circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle);
|
||||||
|
circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h != circle1);
|
||||||
|
EditorClock.Seek(circle1.StartTime);
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(circle1);
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(circle2);
|
||||||
|
});
|
||||||
|
|
||||||
|
mergeSelection();
|
||||||
|
|
||||||
|
AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor(
|
||||||
|
(pos: circle1.Position, pathType: PathType.Linear),
|
||||||
|
(pos: circle2.Position, pathType: null)));
|
||||||
|
|
||||||
|
AddStep("undo", () => Editor.Undo());
|
||||||
|
AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && objectsRestored(circle1, circle2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestMergeCircleSlider()
|
||||||
|
{
|
||||||
|
HitCircle? circle1 = null;
|
||||||
|
Slider? slider = null;
|
||||||
|
HitCircle? circle2 = null;
|
||||||
|
|
||||||
|
AddStep("select a circle, slider, circle", () =>
|
||||||
|
{
|
||||||
|
circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle);
|
||||||
|
slider = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > circle1.StartTime);
|
||||||
|
circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h.StartTime > slider.StartTime);
|
||||||
|
EditorClock.Seek(circle1.StartTime);
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(circle1);
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(slider);
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(circle2);
|
||||||
|
});
|
||||||
|
|
||||||
|
mergeSelection();
|
||||||
|
|
||||||
|
AddAssert("slider created", () =>
|
||||||
|
{
|
||||||
|
if (circle1 is null || circle2 is null || slider is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var controlPoints = slider.Path.ControlPoints;
|
||||||
|
(Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints.Count + 2];
|
||||||
|
args[0] = (circle1.Position, PathType.Linear);
|
||||||
|
|
||||||
|
for (int i = 0; i < controlPoints.Count; i++)
|
||||||
|
{
|
||||||
|
args[i + 1] = (controlPoints[i].Position + slider.Position, i == controlPoints.Count - 1 ? PathType.Linear : controlPoints[i].Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
args[^1] = (circle2.Position, null);
|
||||||
|
return sliderCreatedFor(args);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddStep("undo", () => Editor.Undo());
|
||||||
|
AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && slider is not null && objectsRestored(circle1, slider, circle2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestMergeSliderSlider()
|
||||||
|
{
|
||||||
|
Slider? slider1 = null;
|
||||||
|
SliderPath? slider1Path = null;
|
||||||
|
Slider? slider2 = null;
|
||||||
|
|
||||||
|
AddStep("select two sliders", () =>
|
||||||
|
{
|
||||||
|
slider1 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider);
|
||||||
|
slider1Path = new SliderPath(slider1.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(), slider1.Path.ExpectedDistance.Value);
|
||||||
|
slider2 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > slider1.StartTime);
|
||||||
|
EditorClock.Seek(slider1.StartTime);
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(slider1);
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(slider2);
|
||||||
|
});
|
||||||
|
|
||||||
|
mergeSelection();
|
||||||
|
|
||||||
|
AddAssert("slider created", () =>
|
||||||
|
{
|
||||||
|
if (slider1 is null || slider2 is null || slider1Path is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var controlPoints1 = slider1Path.ControlPoints;
|
||||||
|
var controlPoints2 = slider2.Path.ControlPoints;
|
||||||
|
(Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints1.Count + controlPoints2.Count - 1];
|
||||||
|
|
||||||
|
for (int i = 0; i < controlPoints1.Count - 1; i++)
|
||||||
|
{
|
||||||
|
args[i] = (controlPoints1[i].Position + slider1.Position, controlPoints1[i].Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < controlPoints2.Count; i++)
|
||||||
|
{
|
||||||
|
args[i + controlPoints1.Count - 1] = (controlPoints2[i].Position + controlPoints1[^1].Position + slider1.Position, controlPoints2[i].Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sliderCreatedFor(args);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddAssert("merged slider matches first slider", () =>
|
||||||
|
{
|
||||||
|
var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
|
||||||
|
return slider1 is not null && mergedSlider.HeadCircle.Samples.SequenceEqual(slider1.HeadCircle.Samples)
|
||||||
|
&& mergedSlider.TailCircle.Samples.SequenceEqual(slider1.TailCircle.Samples)
|
||||||
|
&& mergedSlider.Samples.SequenceEqual(slider1.Samples)
|
||||||
|
&& mergedSlider.SampleControlPoint.IsRedundant(slider1.SampleControlPoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddAssert("slider end is at same completion for last slider", () =>
|
||||||
|
{
|
||||||
|
if (slider1Path is null || slider2 is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
|
||||||
|
return Precision.AlmostEquals(mergedSlider.Path.Distance, slider1Path.CalculatedDistance + slider2.Path.Distance);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestNonMerge()
|
||||||
|
{
|
||||||
|
HitCircle? circle1 = null;
|
||||||
|
HitCircle? circle2 = null;
|
||||||
|
Spinner? spinner = null;
|
||||||
|
|
||||||
|
AddStep("select first two circles and spinner", () =>
|
||||||
|
{
|
||||||
|
circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle);
|
||||||
|
circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h != circle1);
|
||||||
|
spinner = (Spinner)EditorBeatmap.HitObjects.First(h => h is Spinner);
|
||||||
|
EditorClock.Seek(spinner.StartTime);
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(circle1);
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(circle2);
|
||||||
|
EditorBeatmap.SelectedHitObjects.Add(spinner);
|
||||||
|
});
|
||||||
|
|
||||||
|
mergeSelection();
|
||||||
|
|
||||||
|
AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor(
|
||||||
|
(pos: circle1.Position, pathType: PathType.Linear),
|
||||||
|
(pos: circle2.Position, pathType: null)));
|
||||||
|
|
||||||
|
AddAssert("spinner not merged", () => EditorBeatmap.HitObjects.Contains(spinner));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mergeSelection()
|
||||||
|
{
|
||||||
|
AddStep("merge selection", () =>
|
||||||
|
{
|
||||||
|
InputManager.PressKey(Key.LControl);
|
||||||
|
InputManager.PressKey(Key.LShift);
|
||||||
|
InputManager.Key(Key.M);
|
||||||
|
InputManager.ReleaseKey(Key.LShift);
|
||||||
|
InputManager.ReleaseKey(Key.LControl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool sliderCreatedFor(params (Vector2 pos, PathType? pathType)[] expectedControlPoints)
|
||||||
|
{
|
||||||
|
if (EditorBeatmap.SelectedHitObjects.Count != 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
|
||||||
|
int i = 0;
|
||||||
|
|
||||||
|
foreach ((Vector2 pos, PathType? pathType) in expectedControlPoints)
|
||||||
|
{
|
||||||
|
var controlPoint = mergedSlider.Path.ControlPoints[i++];
|
||||||
|
|
||||||
|
if (!Precision.AlmostEquals(controlPoint.Position + mergedSlider.Position, pos) || controlPoint.Type != pathType)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool objectsRestored(params HitObject[] objects)
|
||||||
|
{
|
||||||
|
foreach (var hitObject in objects)
|
||||||
|
{
|
||||||
|
if (EditorBeatmap.HitObjects.Contains(hitObject))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -251,13 +251,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
|
|||||||
|
|
||||||
private void convertToStream()
|
private void convertToStream()
|
||||||
{
|
{
|
||||||
if (editorBeatmap == null || changeHandler == null || beatDivisor == null)
|
if (editorBeatmap == null || beatDivisor == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var timingPoint = editorBeatmap.ControlPointInfo.TimingPointAt(HitObject.StartTime);
|
var timingPoint = editorBeatmap.ControlPointInfo.TimingPointAt(HitObject.StartTime);
|
||||||
double streamSpacing = timingPoint.BeatLength / beatDivisor.Value;
|
double streamSpacing = timingPoint.BeatLength / beatDivisor.Value;
|
||||||
|
|
||||||
changeHandler.BeginChange();
|
changeHandler?.BeginChange();
|
||||||
|
|
||||||
int i = 0;
|
int i = 0;
|
||||||
double time = HitObject.StartTime;
|
double time = HitObject.StartTime;
|
||||||
@ -292,7 +292,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
|
|||||||
|
|
||||||
editorBeatmap.Remove(HitObject);
|
editorBeatmap.Remove(HitObject);
|
||||||
|
|
||||||
changeHandler.EndChange();
|
changeHandler?.EndChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override MenuItem[] ContextMenuItems => new MenuItem[]
|
public override MenuItem[] ContextMenuItems => new MenuItem[]
|
||||||
|
@ -6,8 +6,11 @@ using System.Linq;
|
|||||||
using osu.Framework.Allocation;
|
using osu.Framework.Allocation;
|
||||||
using osu.Framework.Graphics;
|
using osu.Framework.Graphics;
|
||||||
using osu.Framework.Graphics.Primitives;
|
using osu.Framework.Graphics.Primitives;
|
||||||
|
using osu.Framework.Graphics.UserInterface;
|
||||||
|
using osu.Framework.Input.Events;
|
||||||
using osu.Framework.Utils;
|
using osu.Framework.Utils;
|
||||||
using osu.Game.Extensions;
|
using osu.Game.Extensions;
|
||||||
|
using osu.Game.Graphics.UserInterface;
|
||||||
using osu.Game.Rulesets.Edit;
|
using osu.Game.Rulesets.Edit;
|
||||||
using osu.Game.Rulesets.Objects;
|
using osu.Game.Rulesets.Objects;
|
||||||
using osu.Game.Rulesets.Objects.Types;
|
using osu.Game.Rulesets.Objects.Types;
|
||||||
@ -15,6 +18,7 @@ using osu.Game.Rulesets.Osu.Objects;
|
|||||||
using osu.Game.Rulesets.Osu.UI;
|
using osu.Game.Rulesets.Osu.UI;
|
||||||
using osu.Game.Screens.Edit.Compose.Components;
|
using osu.Game.Screens.Edit.Compose.Components;
|
||||||
using osuTK;
|
using osuTK;
|
||||||
|
using osuTK.Input;
|
||||||
|
|
||||||
namespace osu.Game.Rulesets.Osu.Edit
|
namespace osu.Game.Rulesets.Osu.Edit
|
||||||
{
|
{
|
||||||
@ -53,6 +57,17 @@ namespace osu.Game.Rulesets.Osu.Edit
|
|||||||
referencePathTypes = null;
|
referencePathTypes = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override bool OnKeyDown(KeyDownEvent e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.M && e.ControlPressed && e.ShiftPressed)
|
||||||
|
{
|
||||||
|
mergeSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public override bool HandleMovement(MoveSelectionEvent<HitObject> moveEvent)
|
public override bool HandleMovement(MoveSelectionEvent<HitObject> moveEvent)
|
||||||
{
|
{
|
||||||
var hitObjects = selectedMovableObjects;
|
var hitObjects = selectedMovableObjects;
|
||||||
@ -320,7 +335,109 @@ namespace osu.Game.Rulesets.Osu.Edit
|
|||||||
/// All osu! hitobjects which can be moved/rotated/scaled.
|
/// All osu! hitobjects which can be moved/rotated/scaled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType<OsuHitObject>()
|
private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType<OsuHitObject>()
|
||||||
.Where(h => !(h is Spinner))
|
.Where(h => h is not Spinner)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All osu! hitobjects which can be merged.
|
||||||
|
/// </summary>
|
||||||
|
private OsuHitObject[] selectedMergeableObjects => SelectedItems.OfType<OsuHitObject>()
|
||||||
|
.Where(h => h is HitCircle or Slider)
|
||||||
|
.OrderBy(h => h.StartTime)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private void mergeSelection()
|
||||||
|
{
|
||||||
|
var mergeableObjects = selectedMergeableObjects;
|
||||||
|
|
||||||
|
if (mergeableObjects.Length < 2)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ChangeHandler?.BeginChange();
|
||||||
|
|
||||||
|
// Have an initial slider object.
|
||||||
|
var firstHitObject = mergeableObjects[0];
|
||||||
|
var mergedHitObject = firstHitObject as Slider ?? new Slider
|
||||||
|
{
|
||||||
|
StartTime = firstHitObject.StartTime,
|
||||||
|
Position = firstHitObject.Position,
|
||||||
|
NewCombo = firstHitObject.NewCombo,
|
||||||
|
SampleControlPoint = firstHitObject.SampleControlPoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mergedHitObject.Path.ControlPoints.Count == 0)
|
||||||
|
{
|
||||||
|
mergedHitObject.Path.ControlPoints.Add(new PathControlPoint(Vector2.Zero, PathType.Linear));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all the selected hit objects into one slider path.
|
||||||
|
bool lastCircle = firstHitObject is HitCircle;
|
||||||
|
|
||||||
|
foreach (var selectedMergeableObject in mergeableObjects.Skip(1))
|
||||||
|
{
|
||||||
|
if (selectedMergeableObject is IHasPath hasPath)
|
||||||
|
{
|
||||||
|
var offset = lastCircle ? selectedMergeableObject.Position - mergedHitObject.Position : mergedHitObject.Path.ControlPoints[^1].Position;
|
||||||
|
float distanceToLastControlPoint = Vector2.Distance(mergedHitObject.Path.ControlPoints[^1].Position, offset);
|
||||||
|
|
||||||
|
// Calculate the distance required to travel to the expected distance of the merging slider.
|
||||||
|
mergedHitObject.Path.ExpectedDistance.Value = mergedHitObject.Path.CalculatedDistance + distanceToLastControlPoint + hasPath.Path.Distance;
|
||||||
|
|
||||||
|
// Remove the last control point if it sits exactly on the start of the next control point.
|
||||||
|
if (Precision.AlmostEquals(distanceToLastControlPoint, 0))
|
||||||
|
{
|
||||||
|
mergedHitObject.Path.ControlPoints.RemoveAt(mergedHitObject.Path.ControlPoints.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedHitObject.Path.ControlPoints.AddRange(hasPath.Path.ControlPoints.Select(o => new PathControlPoint(o.Position + offset, o.Type)));
|
||||||
|
lastCircle = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Turn the last control point into a linear type if this is the first merging circle in a sequence, so the subsequent control points can be inherited path type.
|
||||||
|
if (!lastCircle)
|
||||||
|
{
|
||||||
|
mergedHitObject.Path.ControlPoints.Last().Type = PathType.Linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedHitObject.Path.ControlPoints.Add(new PathControlPoint(selectedMergeableObject.Position - mergedHitObject.Position));
|
||||||
|
mergedHitObject.Path.ExpectedDistance.Value = null;
|
||||||
|
lastCircle = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure only the merged hit object is in the beatmap.
|
||||||
|
if (firstHitObject is Slider)
|
||||||
|
{
|
||||||
|
foreach (var selectedMergeableObject in mergeableObjects.Skip(1))
|
||||||
|
{
|
||||||
|
EditorBeatmap.Remove(selectedMergeableObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var selectedMergeableObject in mergeableObjects)
|
||||||
|
{
|
||||||
|
EditorBeatmap.Remove(selectedMergeableObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorBeatmap.Add(mergedHitObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the merged hitobject is selected.
|
||||||
|
SelectedItems.Clear();
|
||||||
|
SelectedItems.Add(mergedHitObject);
|
||||||
|
|
||||||
|
ChangeHandler?.EndChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<HitObject>> selection)
|
||||||
|
{
|
||||||
|
foreach (var item in base.GetContextMenuItemsForSelection(selection))
|
||||||
|
yield return item;
|
||||||
|
|
||||||
|
if (selectedMergeableObjects.Length > 1)
|
||||||
|
yield return new OsuMenuItem("Merge selection", MenuItemType.Destructive, mergeSelection);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user