diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 9418565907..a0f70ce408 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -1,7 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -10,40 +16,180 @@ namespace osu.Game.Rulesets.Osu.Edit { public class OsuSelectionHandler : SelectionHandler { - public override bool HandleMovement(MoveSelectionEvent moveEvent) + protected override void OnSelectionChanged() { - Vector2 minPosition = new Vector2(float.MaxValue, float.MaxValue); - Vector2 maxPosition = new Vector2(float.MinValue, float.MinValue); + base.OnSelectionChanged(); - // Go through all hitobjects to make sure they would remain in the bounds of the editor after movement, before any movement is attempted - foreach (var h in SelectedHitObjects.OfType()) + bool canOperate = SelectedHitObjects.Count() > 1 || SelectedHitObjects.Any(s => s is Slider); + + SelectionBox.CanRotate = canOperate; + SelectionBox.CanScaleX = canOperate; + SelectionBox.CanScaleY = canOperate; + } + + protected override void OnDragOperationEnded() + { + base.OnDragOperationEnded(); + referenceOrigin = null; + } + + public override bool HandleMovement(MoveSelectionEvent moveEvent) => + moveSelection(moveEvent.InstantDelta); + + /// + /// During a transform, the initial origin is stored so it can be used throughout the operation. + /// + private Vector2? referenceOrigin; + + public override bool HandleScale(Vector2 scale, Anchor reference) + { + adjustScaleFromAnchor(ref scale, reference); + + var hitObjects = selectedMovableObjects; + + // for the time being, allow resizing of slider paths only if the slider is + // the only hit object selected. with a group selection, it's likely the user + // is not looking to change the duration of the slider but expand the whole pattern. + if (hitObjects.Length == 1 && hitObjects.First() is Slider slider) { - if (h is Spinner) - { - // Spinners don't support position adjustments - continue; - } + Quad quad = getSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position.Value)); + Vector2 pathRelativeDeltaScale = new Vector2(1 + scale.X / quad.Width, 1 + scale.Y / quad.Height); - // Stacking is not considered - minPosition = Vector2.ComponentMin(minPosition, Vector2.ComponentMin(h.EndPosition + moveEvent.InstantDelta, h.Position + moveEvent.InstantDelta)); - maxPosition = Vector2.ComponentMax(maxPosition, Vector2.ComponentMax(h.EndPosition + moveEvent.InstantDelta, h.Position + moveEvent.InstantDelta)); + foreach (var point in slider.Path.ControlPoints) + point.Position.Value *= pathRelativeDeltaScale; } - - if (minPosition.X < 0 || minPosition.Y < 0 || maxPosition.X > DrawWidth || maxPosition.Y > DrawHeight) - return false; - - foreach (var h in SelectedHitObjects.OfType()) + else { - if (h is Spinner) - { - // Spinners don't support position adjustments - continue; - } + // move the selection before scaling if dragging from top or left anchors. + if ((reference & Anchor.x0) > 0 && !moveSelection(new Vector2(-scale.X, 0))) return false; + if ((reference & Anchor.y0) > 0 && !moveSelection(new Vector2(0, -scale.Y))) return false; - h.Position += moveEvent.InstantDelta; + Quad quad = getSurroundingQuad(hitObjects); + + foreach (var h in hitObjects) + { + h.Position = new Vector2( + quad.TopLeft.X + (h.X - quad.TopLeft.X) / quad.Width * (quad.Width + scale.X), + quad.TopLeft.Y + (h.Y - quad.TopLeft.Y) / quad.Height * (quad.Height + scale.Y) + ); + } } return true; } + + private static void adjustScaleFromAnchor(ref Vector2 scale, Anchor reference) + { + // cancel out scale in axes we don't care about (based on which drag handle was used). + if ((reference & Anchor.x1) > 0) scale.X = 0; + if ((reference & Anchor.y1) > 0) scale.Y = 0; + + // reverse the scale direction if dragging from top or left. + if ((reference & Anchor.x0) > 0) scale.X = -scale.X; + if ((reference & Anchor.y0) > 0) scale.Y = -scale.Y; + } + + public override bool HandleRotation(float delta) + { + var hitObjects = selectedMovableObjects; + + Quad quad = getSurroundingQuad(hitObjects); + + referenceOrigin ??= quad.Centre; + + foreach (var h in hitObjects) + { + h.Position = rotatePointAroundOrigin(h.Position, referenceOrigin.Value, delta); + + if (h is IHasPath path) + { + foreach (var point in path.Path.ControlPoints) + point.Position.Value = rotatePointAroundOrigin(point.Position.Value, Vector2.Zero, delta); + } + } + + // this isn't always the case but let's be lenient for now. + return true; + } + + private bool moveSelection(Vector2 delta) + { + var hitObjects = selectedMovableObjects; + + Quad quad = getSurroundingQuad(hitObjects); + + if (quad.TopLeft.X + delta.X < 0 || + quad.TopLeft.Y + delta.Y < 0 || + quad.BottomRight.X + delta.X > DrawWidth || + quad.BottomRight.Y + delta.Y > DrawHeight) + return false; + + foreach (var h in hitObjects) + h.Position += delta; + + return true; + } + + /// + /// Returns a gamefield-space quad surrounding the provided hit objects. + /// + /// The hit objects to calculate a quad for. + private Quad getSurroundingQuad(OsuHitObject[] hitObjects) => + getSurroundingQuad(hitObjects.SelectMany(h => new[] { h.Position, h.EndPosition })); + + /// + /// Returns a gamefield-space quad surrounding the provided points. + /// + /// The points to calculate a quad for. + private Quad getSurroundingQuad(IEnumerable points) + { + if (!SelectedHitObjects.Any()) + return new Quad(); + + Vector2 minPosition = new Vector2(float.MaxValue, float.MaxValue); + Vector2 maxPosition = new Vector2(float.MinValue, float.MinValue); + + // Go through all hitobjects to make sure they would remain in the bounds of the editor after movement, before any movement is attempted + foreach (var p in points) + { + minPosition = Vector2.ComponentMin(minPosition, p); + maxPosition = Vector2.ComponentMax(maxPosition, p); + } + + Vector2 size = maxPosition - minPosition; + + return new Quad(minPosition.X, minPosition.Y, size.X, size.Y); + } + + /// + /// All osu! hitobjects which can be moved/rotated/scaled. + /// + private OsuHitObject[] selectedMovableObjects => SelectedHitObjects + .OfType() + .Where(h => !(h is Spinner)) + .ToArray(); + + /// + /// Rotate a point around an arbitrary origin. + /// + /// The point. + /// The centre origin to rotate around. + /// The angle to rotate (in degrees). + private static Vector2 rotatePointAroundOrigin(Vector2 point, Vector2 origin, float angle) + { + angle = -angle; + + point.X -= origin.X; + point.Y -= origin.Y; + + Vector2 ret; + ret.X = point.X * MathF.Cos(MathUtils.DegreesToRadians(angle)) + point.Y * MathF.Sin(MathUtils.DegreesToRadians(angle)); + ret.Y = point.X * -MathF.Sin(MathUtils.DegreesToRadians(angle)) + point.Y * MathF.Cos(MathUtils.DegreesToRadians(angle)); + + ret.X += origin.X; + ret.Y += origin.Y; + + return ret; + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs new file mode 100644 index 0000000000..a1fb91024b --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs @@ -0,0 +1,70 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneComposeSelectBox : OsuTestScene + { + private Container selectionArea; + + public TestSceneComposeSelectBox() + { + ComposeSelectionBox selectionBox = null; + + AddStep("create box", () => + Child = selectionArea = new Container + { + Size = new Vector2(300), + Position = -new Vector2(150), + Anchor = Anchor.Centre, + Children = new Drawable[] + { + selectionBox = new ComposeSelectionBox + { + CanRotate = true, + CanScaleX = true, + CanScaleY = true, + + OnRotation = handleRotation, + OnScale = handleScale + } + } + }); + + AddToggleStep("toggle rotation", state => selectionBox.CanRotate = state); + AddToggleStep("toggle x", state => selectionBox.CanScaleX = state); + AddToggleStep("toggle y", state => selectionBox.CanScaleY = state); + } + + private void handleScale(DragEvent e, Anchor reference) + { + if ((reference & Anchor.y1) == 0) + { + int directionY = (reference & Anchor.y0) > 0 ? -1 : 1; + if (directionY < 0) + selectionArea.Y += e.Delta.Y; + selectionArea.Height += directionY * e.Delta.Y; + } + + if ((reference & Anchor.x1) == 0) + { + int directionX = (reference & Anchor.x0) > 0 ? -1 : 1; + if (directionX < 0) + selectionArea.X += e.Delta.X; + selectionArea.Width += directionX * e.Delta.X; + } + } + + private void handleRotation(DragEvent e) + { + // kinda silly and wrong, but just showing that the drag handles work. + selectionArea.Rotation += e.Delta.X; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeSelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeSelectionBox.cs new file mode 100644 index 0000000000..530c6007cf --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeSelectionBox.cs @@ -0,0 +1,312 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public class ComposeSelectionBox : CompositeDrawable + { + public Action OnRotation; + public Action OnScale; + + public Action OperationStarted; + public Action OperationEnded; + + private bool canRotate; + + /// + /// Whether rotation support should be enabled. + /// + public bool CanRotate + { + get => canRotate; + set + { + canRotate = value; + recreate(); + } + } + + private bool canScaleX; + + /// + /// Whether vertical scale support should be enabled. + /// + public bool CanScaleX + { + get => canScaleX; + set + { + canScaleX = value; + recreate(); + } + } + + private bool canScaleY; + + /// + /// Whether horizontal scale support should be enabled. + /// + public bool CanScaleY + { + get => canScaleY; + set + { + canScaleY = value; + recreate(); + } + } + + public const float BORDER_RADIUS = 3; + + [Resolved] + private OsuColour colours { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + recreate(); + } + + private void recreate() + { + if (LoadState < LoadState.Loading) + return; + + InternalChildren = new Drawable[] + { + new Container + { + Masking = true, + BorderThickness = BORDER_RADIUS, + BorderColour = colours.YellowDark, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + + AlwaysPresent = true, + Alpha = 0 + }, + } + }, + }; + + if (CanRotate) + { + const float separation = 40; + + AddRangeInternal(new Drawable[] + { + new Box + { + Colour = colours.YellowLight, + Blending = BlendingParameters.Additive, + Alpha = 0.3f, + Size = new Vector2(BORDER_RADIUS, separation), + Anchor = Anchor.TopCentre, + Origin = Anchor.BottomCentre, + }, + new RotationDragHandle + { + Anchor = Anchor.TopCentre, + Y = -separation, + HandleDrag = e => OnRotation?.Invoke(e), + OperationStarted = operationStarted, + OperationEnded = operationEnded + } + }); + } + + if (CanScaleY) + { + AddRangeInternal(new[] + { + createDragHandle(Anchor.TopCentre), + createDragHandle(Anchor.BottomCentre), + }); + } + + if (CanScaleX) + { + AddRangeInternal(new[] + { + createDragHandle(Anchor.CentreLeft), + createDragHandle(Anchor.CentreRight), + }); + } + + if (CanScaleX && CanScaleY) + { + AddRangeInternal(new[] + { + createDragHandle(Anchor.TopLeft), + createDragHandle(Anchor.TopRight), + createDragHandle(Anchor.BottomLeft), + createDragHandle(Anchor.BottomRight), + }); + } + + ScaleDragHandle createDragHandle(Anchor anchor) => + new ScaleDragHandle(anchor) + { + HandleDrag = e => OnScale?.Invoke(e, anchor), + OperationStarted = operationStarted, + OperationEnded = operationEnded + }; + } + + private int activeOperations; + + private void operationEnded() + { + if (--activeOperations == 0) + OperationEnded?.Invoke(); + } + + private void operationStarted() + { + if (activeOperations++ == 0) + OperationStarted?.Invoke(); + } + + private class ScaleDragHandle : DragHandle + { + public ScaleDragHandle(Anchor anchor) + { + Anchor = anchor; + } + } + + private class RotationDragHandle : DragHandle + { + private SpriteIcon icon; + + [BackgroundDependencyLoader] + private void load() + { + Size *= 2; + + AddInternal(icon = new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f), + Icon = FontAwesome.Solid.Redo, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + protected override void UpdateHoverState() + { + base.UpdateHoverState(); + icon.Colour = !HandlingMouse && IsHovered ? Color4.White : Color4.Black; + } + } + + private class DragHandle : Container + { + public Action OperationStarted; + public Action OperationEnded; + + public Action HandleDrag { get; set; } + + private Circle circle; + + [Resolved] + private OsuColour colours { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(10); + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + circle = new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + UpdateHoverState(); + } + + protected override bool OnHover(HoverEvent e) + { + UpdateHoverState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + UpdateHoverState(); + } + + protected bool HandlingMouse; + + protected override bool OnMouseDown(MouseDownEvent e) + { + HandlingMouse = true; + UpdateHoverState(); + return true; + } + + protected override bool OnDragStart(DragStartEvent e) + { + OperationStarted?.Invoke(); + return true; + } + + protected override void OnDrag(DragEvent e) + { + HandleDrag?.Invoke(e); + base.OnDrag(e); + } + + protected override void OnDragEnd(DragEndEvent e) + { + HandlingMouse = false; + OperationEnded?.Invoke(); + + UpdateHoverState(); + base.OnDragEnd(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + HandlingMouse = false; + UpdateHoverState(); + base.OnMouseUp(e); + } + + protected virtual void UpdateHoverState() + { + circle.Colour = HandlingMouse ? colours.GrayF : (IsHovered ? colours.Red : colours.YellowDark); + this.ScaleTo(HandlingMouse || IsHovered ? 1.5f : 1, 100, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs index 0615ebfc20..0ec981203a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs @@ -45,7 +45,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { Masking = true, BorderColour = Color4.White, - BorderThickness = SelectionHandler.BORDER_RADIUS, + BorderThickness = ComposeSelectionBox.BORDER_RADIUS, Child = new Box { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index a0220cf987..ee094c6246 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -32,8 +32,6 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public class SelectionHandler : CompositeDrawable, IKeyBindingHandler, IHasContextMenu { - public const float BORDER_RADIUS = 2; - public IEnumerable SelectedBlueprints => selectedBlueprints; private readonly List selectedBlueprints; @@ -45,6 +43,8 @@ namespace osu.Game.Screens.Edit.Compose.Components private OsuSpriteText selectionDetailsText; + protected ComposeSelectionBox SelectionBox { get; private set; } + [Resolved(CanBeNull = true)] protected EditorBeatmap EditorBeatmap { get; private set; } @@ -69,19 +69,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { Children = new Drawable[] { - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = BORDER_RADIUS, - BorderColour = colours.YellowDark, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - AlwaysPresent = true, - Alpha = 0 - } - }, + // todo: should maybe be inside the SelectionBox? new Container { Name = "info text", @@ -100,11 +88,38 @@ namespace osu.Game.Screens.Edit.Compose.Components Font = OsuFont.Default.With(size: 11) } } - } + }, + SelectionBox = CreateSelectionBox(), } }; } + public ComposeSelectionBox CreateSelectionBox() + => new ComposeSelectionBox + { + OperationStarted = OnDragOperationBegan, + OperationEnded = OnDragOperationEnded, + + OnRotation = e => HandleRotation(e.Delta.X), + OnScale = (e, anchor) => HandleScale(e.Delta, anchor), + }; + + /// + /// Fired when a drag operation ends from the selection box. + /// + protected virtual void OnDragOperationBegan() + { + ChangeHandler.BeginChange(); + } + + /// + /// Fired when a drag operation begins from the selection box. + /// + protected virtual void OnDragOperationEnded() + { + ChangeHandler.EndChange(); + } + #region User Input Handling /// @@ -119,7 +134,22 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Whether any s could be moved. /// Returning true will also propagate StartTime changes provided by the closest . /// - public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => true; + public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => false; + + /// + /// Handles the selected s being rotated. + /// + /// The delta angle to apply to the selection. + /// Whether any s could be moved. + public virtual bool HandleRotation(float angle) => false; + + /// + /// Handles the selected s being scaled. + /// + /// The delta scale to apply, in playfield local coordinates. + /// The point of reference where the scale is originating from. + /// Whether any s could be moved. + public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false; public bool OnPressed(PlatformAction action) { @@ -222,11 +252,22 @@ namespace osu.Game.Screens.Edit.Compose.Components selectionDetailsText.Text = count > 0 ? count.ToString() : string.Empty; if (count > 0) + { Show(); + OnSelectionChanged(); + } else Hide(); } + /// + /// Triggered whenever more than one object is selected, on each change. + /// Should update the selection box's state to match supported operations. + /// + protected virtual void OnSelectionChanged() + { + } + protected override void Update() { base.Update();