diff --git a/osu.Android.props b/osu.Android.props
index 32e236ccd5..0bb0bf171c 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,6 +52,6 @@
-
+
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderLengthValidity.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderLengthValidity.cs
new file mode 100644
index 0000000000..ce529f2a88
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderLengthValidity.cs
@@ -0,0 +1,198 @@
+// 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.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Screens.Edit.Compose.Components;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor
+{
+ [TestFixture]
+ public class TestSceneSliderLengthValidity : TestSceneOsuEditor
+ {
+ private OsuPlayfield playfield;
+
+ protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(Ruleset.Value, false);
+
+ public override void SetUpSteps()
+ {
+ base.SetUpSteps();
+ AddStep("get playfield", () => playfield = Editor.ChildrenOfType().First());
+ AddStep("seek to first timing point", () => EditorClock.Seek(Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.First().Time));
+ }
+
+ [Test]
+ public void TestDraggingStartingPointRemainsValid()
+ {
+ Slider slider = null;
+
+ AddStep("Add slider", () =>
+ {
+ slider = new Slider { StartTime = EditorClock.CurrentTime, Position = new Vector2(300) };
+
+ PathControlPoint[] points =
+ {
+ new PathControlPoint(new Vector2(0), PathType.Linear),
+ new PathControlPoint(new Vector2(100, 0)),
+ };
+
+ slider.Path = new SliderPath(points);
+ EditorBeatmap.Add(slider);
+ });
+
+ AddAssert("ensure object placed", () => EditorBeatmap.HitObjects.Count == 1);
+
+ moveMouse(new Vector2(300));
+ AddStep("select slider", () => InputManager.Click(MouseButton.Left));
+
+ double distanceBefore = 0;
+
+ AddStep("store distance", () => distanceBefore = slider.Path.Distance);
+
+ moveMouse(new Vector2(300, 300));
+
+ AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left));
+ moveMouse(new Vector2(350, 300));
+ moveMouse(new Vector2(400, 300));
+ AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left));
+
+ AddAssert("slider length shrunk", () => slider.Path.Distance < distanceBefore);
+ AddAssert("ensure slider still has valid length", () => slider.Path.Distance > 0);
+ }
+
+ [Test]
+ public void TestDraggingEndingPointRemainsValid()
+ {
+ Slider slider = null;
+
+ AddStep("Add slider", () =>
+ {
+ slider = new Slider { StartTime = EditorClock.CurrentTime, Position = new Vector2(300) };
+
+ PathControlPoint[] points =
+ {
+ new PathControlPoint(new Vector2(0), PathType.Linear),
+ new PathControlPoint(new Vector2(100, 0)),
+ };
+
+ slider.Path = new SliderPath(points);
+ EditorBeatmap.Add(slider);
+ });
+
+ AddAssert("ensure object placed", () => EditorBeatmap.HitObjects.Count == 1);
+
+ moveMouse(new Vector2(300));
+ AddStep("select slider", () => InputManager.Click(MouseButton.Left));
+
+ double distanceBefore = 0;
+
+ AddStep("store distance", () => distanceBefore = slider.Path.Distance);
+
+ moveMouse(new Vector2(400, 300));
+
+ AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left));
+ moveMouse(new Vector2(350, 300));
+ moveMouse(new Vector2(300, 300));
+ AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left));
+
+ AddAssert("slider length shrunk", () => slider.Path.Distance < distanceBefore);
+ AddAssert("ensure slider still has valid length", () => slider.Path.Distance > 0);
+ }
+
+ ///
+ /// If a control point is deleted which results in the slider becoming so short it can't exist,
+ /// for simplicity delete the slider rather than having it in an invalid state.
+ ///
+ /// Eventually we may need to change this, based on user feedback. I think it's likely enough of
+ /// an edge case that we won't get many complaints, though (and there's always the undo button).
+ ///
+ [Test]
+ public void TestDeletingPointCausesSliderDeletion()
+ {
+ AddStep("Add slider", () =>
+ {
+ Slider slider = new Slider { StartTime = EditorClock.CurrentTime, Position = new Vector2(300) };
+
+ PathControlPoint[] points =
+ {
+ new PathControlPoint(new Vector2(0), PathType.PerfectCurve),
+ new PathControlPoint(new Vector2(100, 0)),
+ new PathControlPoint(new Vector2(0, 10))
+ };
+
+ slider.Path = new SliderPath(points);
+ EditorBeatmap.Add(slider);
+ });
+
+ AddAssert("ensure object placed", () => EditorBeatmap.HitObjects.Count == 1);
+
+ AddStep("select slider", () => InputManager.Click(MouseButton.Left));
+
+ moveMouse(new Vector2(400, 300));
+ AddStep("delete second point", () =>
+ {
+ InputManager.PressKey(Key.ShiftLeft);
+ InputManager.Click(MouseButton.Right);
+ InputManager.ReleaseKey(Key.ShiftLeft);
+ });
+
+ AddAssert("ensure object deleted", () => EditorBeatmap.HitObjects.Count == 0);
+ }
+
+ ///
+ /// If a scale operation is performed where a single slider is the only thing selected, the path's shape will change.
+ /// If the scale results in the path becoming too short, further mouse movement in the same direction will not change the shape.
+ ///
+ [Test]
+ public void TestScalingSliderTooSmallRemainsValid()
+ {
+ Slider slider = null;
+
+ AddStep("Add slider", () =>
+ {
+ slider = new Slider { StartTime = EditorClock.CurrentTime, Position = new Vector2(300, 200) };
+
+ PathControlPoint[] points =
+ {
+ new PathControlPoint(new Vector2(0), PathType.Linear),
+ new PathControlPoint(new Vector2(0, 50)),
+ new PathControlPoint(new Vector2(0, 100))
+ };
+
+ slider.Path = new SliderPath(points);
+ EditorBeatmap.Add(slider);
+ });
+
+ AddAssert("ensure object placed", () => EditorBeatmap.HitObjects.Count == 1);
+
+ moveMouse(new Vector2(300));
+ AddStep("select slider", () => InputManager.Click(MouseButton.Left));
+
+ double distanceBefore = 0;
+
+ AddStep("store distance", () => distanceBefore = slider.Path.Distance);
+
+ AddStep("move mouse to handle", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().Skip(1).First()));
+ AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left));
+ moveMouse(new Vector2(300, 300));
+ moveMouse(new Vector2(300, 250));
+ moveMouse(new Vector2(300, 200));
+ AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left));
+
+ AddAssert("slider length shrunk", () => slider.Path.Distance < distanceBefore);
+ AddAssert("ensure slider still has valid length", () => slider.Path.Distance > 0);
+ }
+
+ private void moveMouse(Vector2 pos) =>
+ AddStep($"move mouse to {pos}", () => InputManager.MoveMouseTo(playfield.ToScreenSpace(pos)));
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
index d2c37061f0..8235e1bc79 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
@@ -41,9 +41,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
addClickStep(MouseButton.Left);
addClickStep(MouseButton.Right);
- assertPlaced(true);
- assertLength(0);
- assertControlPointType(0, PathType.Linear);
+ assertPlaced(false);
}
[Test]
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
index ce9580d0f4..48e4db11ca 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
@@ -185,6 +185,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
protected override void OnDrag(DragEvent e)
{
+ Vector2[] oldControlPoints = slider.Path.ControlPoints.Select(cp => cp.Position.Value).ToArray();
+ var oldPosition = slider.Position;
+ var oldStartTime = slider.StartTime;
+
if (ControlPoint == slider.Path.ControlPoints[0])
{
// Special handling for the head control point - the position of the slider changes which means the snapped position and time have to be taken into account
@@ -202,6 +206,16 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
else
ControlPoint.Position.Value = dragStartPosition + (e.MousePosition - e.MouseDownPosition);
+ if (!slider.Path.HasValidLength)
+ {
+ for (var i = 0; i < slider.Path.ControlPoints.Count; i++)
+ slider.Path.ControlPoints[i].Position.Value = oldControlPoints[i];
+
+ slider.Position = oldPosition;
+ slider.StartTime = oldStartTime;
+ return;
+ }
+
// Maintain the path type in case it got defaulted to bezier at some point during the drag.
PointsInSegment[0].Type.Value = dragPathType;
}
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs
index efa249694a..77ea3b05dc 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs
@@ -135,7 +135,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private void endCurve()
{
updateSlider();
- EndPlacement(true);
+ EndPlacement(HitObject.Path.HasValidLength);
}
protected override void Update()
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs
index ba9bb3c485..88fcb1e715 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs
@@ -215,7 +215,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
}
// If there are 0 or 1 remaining control points, the slider is in a degenerate (single point) form and should be deleted
- if (controlPoints.Count <= 1)
+ if (controlPoints.Count <= 1 || !slider.HitObject.Path.HasValidLength)
{
placementHandler?.Delete(HitObject);
return;
diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
index 047634a3ab..de0a4682a3 100644
--- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
+++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
@@ -228,7 +228,7 @@ namespace osu.Game.Rulesets.Osu.Edit
Quad scaledQuad = getSurroundingQuad(new OsuHitObject[] { slider });
(bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad);
- if (xInBounds && yInBounds)
+ if (xInBounds && yInBounds && slider.Path.HasValidLength)
return;
foreach (var point in slider.Path.ControlPoints)
diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs
index 4879590e24..7f48888abe 100644
--- a/osu.Game/Rulesets/Mods/Mod.cs
+++ b/osu.Game/Rulesets/Mods/Mod.cs
@@ -7,6 +7,7 @@ using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using osu.Framework.Bindables;
+using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Game.Configuration;
@@ -170,7 +171,12 @@ namespace osu.Game.Rulesets.Mods
target.UnbindFrom(sourceBindable);
}
else
- target.Parse(source);
+ {
+ if (!(target is IParseable parseable))
+ throw new InvalidOperationException($"Bindable type {target.GetType().ReadableName()} is not {nameof(IParseable)}.");
+
+ parseable.Parse(source);
+ }
}
public bool Equals(IMod other) => other is Mod them && Equals(them);
diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs
index e64298f98d..55ef0bc5f6 100644
--- a/osu.Game/Rulesets/Objects/SliderPath.cs
+++ b/osu.Game/Rulesets/Objects/SliderPath.cs
@@ -30,6 +30,8 @@ namespace osu.Game.Rulesets.Objects
///
public readonly Bindable ExpectedDistance = new Bindable();
+ public bool HasValidLength => Distance > 0;
+
///
/// The control points of the path.
///
diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
index bea1aa2e3a..105e04d441 100644
--- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs
@@ -40,7 +40,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private Bindable indexInCurrentComboBindable;
private Bindable comboIndexBindable;
- private readonly Drawable circle;
+ private readonly ExtendableCircle circle;
+ private readonly Border border;
private readonly Container colouredComponents;
private readonly OsuSpriteText comboIndexText;
@@ -62,7 +63,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
RelativeSizeAxes = Axes.X;
Height = circle_size;
- AddRangeInternal(new[]
+ AddRangeInternal(new Drawable[]
{
circle = new ExtendableCircle
{
@@ -70,6 +71,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
+ border = new Border
+ {
+ RelativeSizeAxes = Axes.Both,
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ },
colouredComponents = new Container
{
Anchor = Anchor.CentreLeft,
@@ -116,11 +123,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
protected override void OnSelected()
{
// base logic hides selected blueprints when not selected, but timeline doesn't do that.
+ updateComboColour();
}
protected override void OnDeselected()
{
// base logic hides selected blueprints when not selected, but timeline doesn't do that.
+ updateComboColour();
}
private void updateComboIndex() => comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString();
@@ -133,6 +142,16 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
var comboColours = skin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty();
var comboColour = combo.GetComboColour(comboColours);
+ if (IsSelected)
+ {
+ border.Show();
+ comboColour = comboColour.Lighten(0.3f);
+ }
+ else
+ {
+ border.Hide();
+ }
+
if (HitObject is IHasDuration duration && duration.Duration > 0)
circle.Colour = ColourInfo.GradientHorizontal(comboColour, comboColour.Lighten(0.4f));
else
@@ -340,22 +359,38 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
}
}
+ public class Border : ExtendableCircle
+ {
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ Content.Child.Alpha = 0;
+ Content.Child.AlwaysPresent = true;
+
+ Content.BorderColour = colours.Yellow;
+ Content.EdgeEffect = new EdgeEffectParameters();
+ }
+ }
+
///
/// A circle with externalised end caps so it can take up the full width of a relative width area.
///
public class ExtendableCircle : CompositeDrawable
{
- private readonly Circle content;
+ protected readonly Circle Content;
- public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => content.ReceivePositionalInputAt(screenSpacePos);
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Content.ReceivePositionalInputAt(screenSpacePos);
- public override Quad ScreenSpaceDrawQuad => content.ScreenSpaceDrawQuad;
+ public override Quad ScreenSpaceDrawQuad => Content.ScreenSpaceDrawQuad;
public ExtendableCircle()
{
Padding = new MarginPadding { Horizontal = -circle_size / 2f };
- InternalChild = content = new Circle
+ InternalChild = Content = new Circle
{
+ BorderColour = OsuColour.Gray(0.75f),
+ BorderThickness = 4,
+ Masking = true,
RelativeSizeAxes = Axes.Both,
EdgeEffect = new EdgeEffectParameters
{
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index b5405f6262..e0a267241d 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -29,7 +29,7 @@
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index 09f6033bfe..bcd953c0bd 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -70,7 +70,7 @@
-
+
@@ -93,7 +93,7 @@
-
+