1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-16 16:30:05 +08:00
osu-lazer/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs

430 lines
18 KiB
C#
Raw Normal View History

// 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.
2022-06-17 15:37:17 +08:00
#nullable disable
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
2019-11-13 16:36:46 +08:00
using Humanizer;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.UserInterface;
2019-10-31 16:25:30 +08:00
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
2019-10-31 15:23:54 +08:00
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
2019-12-06 16:03:54 +08:00
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit;
using osuTK;
using osuTK.Input;
2018-11-07 15:08:56 +08:00
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
{
2023-01-26 16:12:41 +08:00
public partial class PathControlPointVisualiser<T> : CompositeDrawable, IKeyBindingHandler<PlatformAction>, IHasContextMenu
where T : OsuHitObject, IHasPath
{
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // allow context menu to appear outside of the playfield.
internal readonly Container<PathControlPointPiece<T>> Pieces;
internal readonly Container<PathControlPointConnectionPiece<T>> Connections;
private readonly IBindableList<PathControlPoint> controlPoints = new BindableList<PathControlPoint>();
private readonly T hitObject;
private readonly bool allowSelection;
2019-10-31 16:25:30 +08:00
private InputManager inputManager;
public Action<List<PathControlPoint>> RemoveControlPointsRequested;
2022-08-18 07:29:03 +08:00
public Action<List<PathControlPoint>> SplitControlPointsRequested;
[Resolved(CanBeNull = true)]
private IPositionSnapProvider positionSnapProvider { get; set; }
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider distanceSnapProvider { get; set; }
public PathControlPointVisualiser(T hitObject, bool allowSelection)
{
this.hitObject = hitObject;
this.allowSelection = allowSelection;
2019-10-31 15:23:54 +08:00
RelativeSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
{
Connections = new Container<PathControlPointConnectionPiece<T>> { RelativeSizeAxes = Axes.Both },
Pieces = new Container<PathControlPointPiece<T>> { RelativeSizeAxes = Axes.Both }
};
2018-11-09 12:58:46 +08:00
}
2019-10-31 16:25:30 +08:00
protected override void LoadComplete()
{
base.LoadComplete();
inputManager = GetContainingInputManager();
controlPoints.CollectionChanged += onControlPointsChanged;
controlPoints.BindTo(hitObject.Path.ControlPoints);
}
2019-10-31 16:13:10 +08:00
/// <summary>
/// Selects the <see cref="PathControlPointPiece{T}"/> corresponding to the given <paramref name="pathControlPoint"/>,
/// and deselects all other <see cref="PathControlPointPiece{T}"/>s.
/// </summary>
public void SetSelectionTo(PathControlPoint pathControlPoint)
{
foreach (var p in Pieces)
p.IsSelected.Value = p.ControlPoint == pathControlPoint;
}
/// <summary>
/// Delete all visually selected <see cref="PathControlPoint"/>s.
/// </summary>
/// <returns></returns>
public bool DeleteSelected()
{
List<PathControlPoint> toRemove = Pieces.Where(p => p.IsSelected.Value).Select(p => p.ControlPoint).ToList();
// Ensure that there are any points to be deleted
if (toRemove.Count == 0)
return false;
changeHandler?.BeginChange();
RemoveControlPointsRequested?.Invoke(toRemove);
changeHandler?.EndChange();
// Since pieces are re-used, they will not point to the deleted control points while remaining selected
foreach (var piece in Pieces)
piece.IsSelected.Value = false;
return true;
}
2022-08-18 07:29:03 +08:00
private bool splitSelected()
{
List<PathControlPoint> controlPointsToSplitAt = Pieces.Where(p => p.IsSelected.Value && isSplittable(p)).Select(p => p.ControlPoint).ToList();
2022-08-18 07:29:03 +08:00
// Ensure that there are any points to be split
if (controlPointsToSplitAt.Count == 0)
2022-08-18 07:29:03 +08:00
return false;
changeHandler?.BeginChange();
SplitControlPointsRequested?.Invoke(controlPointsToSplitAt);
2022-08-18 07:29:03 +08:00
changeHandler?.EndChange();
// Since pieces are re-used, they will not point to the deleted control points while remaining selected
foreach (var piece in Pieces)
piece.IsSelected.Value = false;
return true;
}
private bool isSplittable(PathControlPointPiece<T> p) =>
// A hit object can only be split on control points which connect two different path segments.
p.ControlPoint.Type.HasValue && p != Pieces.FirstOrDefault() && p != Pieces.LastOrDefault();
private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
2022-12-16 19:18:02 +08:00
Debug.Assert(e.NewItems != null);
// If inserting in the path (not appending),
// update indices of existing connections after insert location
if (e.NewStartingIndex < Pieces.Count)
{
foreach (var connection in Connections)
{
if (connection.ControlPointIndex >= e.NewStartingIndex)
connection.ControlPointIndex += e.NewItems.Count;
}
}
for (int i = 0; i < e.NewItems.Count; i++)
{
var point = (PathControlPoint)e.NewItems[i];
Pieces.Add(new PathControlPointPiece<T>(hitObject, point).With(d =>
{
if (allowSelection)
2021-12-22 17:03:58 +08:00
d.RequestSelection = selectionRequested;
d.DragStarted = DragStarted;
d.DragInProgress = DragInProgress;
d.DragEnded = DragEnded;
}));
Connections.Add(new PathControlPointConnectionPiece<T>(hitObject, e.NewStartingIndex + i));
}
break;
case NotifyCollectionChangedAction.Remove:
2022-12-16 19:18:02 +08:00
Debug.Assert(e.OldItems != null);
foreach (var point in e.OldItems.Cast<PathControlPoint>())
{
foreach (var piece in Pieces.Where(p => p.ControlPoint == point).ToArray())
piece.RemoveAndDisposeImmediately();
foreach (var connection in Connections.Where(c => c.ControlPoint == point).ToArray())
connection.RemoveAndDisposeImmediately();
}
// If removing before the end of the path,
// update indices of connections after remove location
2020-11-18 06:04:38 +08:00
if (e.OldStartingIndex < Pieces.Count)
{
foreach (var connection in Connections)
{
if (connection.ControlPointIndex >= e.OldStartingIndex)
connection.ControlPointIndex -= e.OldItems.Count;
}
}
break;
}
}
2019-10-31 15:23:54 +08:00
2019-10-31 16:13:10 +08:00
protected override bool OnClick(ClickEvent e)
2019-10-31 15:23:54 +08:00
{
2021-12-22 17:03:58 +08:00
if (Pieces.Any(piece => piece.IsHovered))
return false;
2019-10-31 15:51:58 +08:00
foreach (var piece in Pieces)
{
2019-10-31 16:13:10 +08:00
piece.IsSelected.Value = false;
}
2019-10-31 16:13:10 +08:00
return false;
}
2019-10-31 15:23:54 +08:00
2021-09-16 17:26:12 +08:00
public bool OnPressed(KeyBindingPressEvent<PlatformAction> e)
2019-10-31 16:13:10 +08:00
{
2021-09-16 17:26:12 +08:00
switch (e.Action)
{
2021-07-20 13:23:34 +08:00
case PlatformAction.Delete:
return DeleteSelected();
}
return false;
}
2021-09-16 17:26:12 +08:00
public void OnReleased(KeyBindingReleaseEvent<PlatformAction> e)
{
}
private void selectionRequested(PathControlPointPiece<T> piece, MouseButtonEvent e)
2019-10-31 16:13:10 +08:00
{
if (e.Button == MouseButton.Left && inputManager.CurrentState.Keyboard.ControlPressed)
piece.IsSelected.Toggle();
2019-10-31 16:25:30 +08:00
else
2021-12-22 17:03:58 +08:00
SetSelectionTo(piece.ControlPoint);
}
2021-04-08 15:05:35 +08:00
/// <summary>
/// Attempts to set the given control point piece to the given path type.
/// If that would fail, try to change the path such that it instead succeeds
/// in a UX-friendly way.
/// </summary>
/// <param name="piece">The control point piece that we want to change the path type of.</param>
/// <param name="type">The path type we want to assign to the given control point piece.</param>
private void updatePathType(PathControlPointPiece<T> piece, PathType? type)
2021-04-08 15:05:35 +08:00
{
2021-04-08 15:06:28 +08:00
int indexInSegment = piece.PointsInSegment.IndexOf(piece.ControlPoint);
2023-11-20 23:17:25 +08:00
if (type?.Type == SplineType.PerfectCurve)
2021-04-08 15:06:28 +08:00
{
// Can't always create a circular arc out of 4 or more points,
// so we split the segment into one 3-point circular arc segment
// and one segment of the previous type.
int thirdPointIndex = indexInSegment + 2;
2021-04-08 17:46:00 +08:00
if (piece.PointsInSegment.Count > thirdPointIndex + 1)
piece.PointsInSegment[thirdPointIndex].Type = piece.PointsInSegment[0].Type;
2021-04-08 15:06:28 +08:00
}
2022-12-07 17:11:57 +08:00
hitObject.Path.ExpectedDistance.Value = null;
piece.ControlPoint.Type = type;
2021-04-08 15:05:35 +08:00
}
[Resolved(CanBeNull = true)]
private IEditorChangeHandler changeHandler { get; set; }
#region Drag handling
private Vector2[] dragStartPositions;
private PathType?[] dragPathTypes;
private int draggedControlPointIndex;
private HashSet<PathControlPoint> selectedControlPoints;
public void DragStarted(PathControlPoint controlPoint)
{
dragStartPositions = hitObject.Path.ControlPoints.Select(point => point.Position).ToArray();
dragPathTypes = hitObject.Path.ControlPoints.Select(point => point.Type).ToArray();
draggedControlPointIndex = hitObject.Path.ControlPoints.IndexOf(controlPoint);
selectedControlPoints = new HashSet<PathControlPoint>(Pieces.Where(piece => piece.IsSelected.Value).Select(piece => piece.ControlPoint));
Debug.Assert(draggedControlPointIndex >= 0);
changeHandler?.BeginChange();
}
public void DragInProgress(DragEvent e)
{
Vector2[] oldControlPoints = hitObject.Path.ControlPoints.Select(cp => cp.Position).ToArray();
var oldPosition = hitObject.Position;
double oldStartTime = hitObject.StartTime;
if (selectedControlPoints.Contains(hitObject.Path.ControlPoints[0]))
{
// Special handling for selections containing head control point - the position of the hit object changes which means the snapped position and time have to be taken into account
Vector2 newHeadPosition = Parent!.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex]));
var result = positionSnapProvider?.FindSnappedPositionAndTime(newHeadPosition);
Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - hitObject.Position;
hitObject.Position += movementDelta;
hitObject.StartTime = result?.Time ?? hitObject.StartTime;
for (int i = 1; i < hitObject.Path.ControlPoints.Count; i++)
{
var controlPoint = hitObject.Path.ControlPoints[i];
// Since control points are relative to the position of the hit object, all points that are _not_ selected
// need to be offset _back_ by the delta corresponding to the movement of the head point.
// All other selected control points (if any) will move together with the head point
// (and so they will not move at all, relative to each other).
if (!selectedControlPoints.Contains(controlPoint))
controlPoint.Position -= movementDelta;
}
}
else
{
var result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids);
Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position;
for (int i = 0; i < controlPoints.Count; ++i)
{
var controlPoint = controlPoints[i];
if (selectedControlPoints.Contains(controlPoint))
controlPoint.Position = dragStartPositions[i] + movementDelta;
}
}
// Snap the path to the current beat divisor before checking length validity.
hitObject.SnapTo(distanceSnapProvider);
if (!hitObject.Path.HasValidLength)
{
for (int i = 0; i < hitObject.Path.ControlPoints.Count; i++)
hitObject.Path.ControlPoints[i].Position = oldControlPoints[i];
hitObject.Position = oldPosition;
hitObject.StartTime = oldStartTime;
// Snap the path length again to undo the invalid length.
hitObject.SnapTo(distanceSnapProvider);
return;
}
// Maintain the path types in case they got defaulted to bezier at some point during the drag.
for (int i = 0; i < hitObject.Path.ControlPoints.Count; i++)
hitObject.Path.ControlPoints[i].Type = dragPathTypes[i];
}
public void DragEnded() => changeHandler?.EndChange();
#endregion
public MenuItem[] ContextMenuItems
{
get
{
2019-11-13 16:38:34 +08:00
if (!Pieces.Any(p => p.IsHovered))
return null;
2019-10-31 16:58:33 +08:00
var selectedPieces = Pieces.Where(p => p.IsSelected.Value).ToList();
int count = selectedPieces.Count;
2019-10-31 16:58:33 +08:00
if (count == 0)
2019-11-13 15:56:48 +08:00
return null;
var splittablePieces = selectedPieces.Where(isSplittable).ToList();
2022-08-18 07:29:03 +08:00
int splittableCount = splittablePieces.Count;
List<MenuItem> curveTypeItems = new List<MenuItem>();
if (!selectedPieces.Contains(Pieces[0]))
2023-11-21 15:14:30 +08:00
{
curveTypeItems.Add(createMenuItemForPathType(null));
2023-11-21 15:14:30 +08:00
curveTypeItems.Add(new OsuMenuItemSpacer());
}
// todo: hide/disable items which aren't valid for selected points
curveTypeItems.Add(createMenuItemForPathType(PathType.LINEAR));
2023-11-13 15:24:09 +08:00
curveTypeItems.Add(createMenuItemForPathType(PathType.PERFECT_CURVE));
curveTypeItems.Add(createMenuItemForPathType(PathType.BEZIER));
2023-12-06 23:35:59 +08:00
curveTypeItems.Add(createMenuItemForPathType(PathType.BSpline(4)));
2023-11-15 14:45:28 +08:00
if (selectedPieces.Any(piece => piece.ControlPoint.Type?.Type == SplineType.Catmull))
2023-11-15 14:45:28 +08:00
curveTypeItems.Add(createMenuItemForPathType(PathType.CATMULL));
2022-08-18 07:29:03 +08:00
var menuItems = new List<MenuItem>
{
new OsuMenuItem("Curve type")
{
Items = curveTypeItems
}
};
2022-08-18 07:29:03 +08:00
if (splittableCount > 0)
{
menuItems.Add(new OsuMenuItem($"Split {"control point".ToQuantity(splittableCount, splittableCount > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}",
MenuItemType.Destructive,
() => splitSelected()));
2022-08-18 07:29:03 +08:00
}
menuItems.Add(
new OsuMenuItem($"Delete {"control point".ToQuantity(count, count > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}",
MenuItemType.Destructive,
() => DeleteSelected())
);
2022-08-18 07:29:03 +08:00
return menuItems.ToArray();
}
}
private MenuItem createMenuItemForPathType(PathType? type)
{
int totalCount = Pieces.Count(p => p.IsSelected.Value);
int countOfState = Pieces.Where(p => p.IsSelected.Value).Count(p => p.ControlPoint.Type == type);
2023-11-20 23:17:25 +08:00
var item = new TernaryStateRadioMenuItem(type?.Description ?? "Inherit", MenuItemType.Standard, _ =>
{
changeHandler?.BeginChange();
foreach (var p in Pieces.Where(p => p.IsSelected.Value))
2021-04-08 15:05:35 +08:00
updatePathType(p, type);
changeHandler?.EndChange();
});
if (countOfState == totalCount)
item.State.Value = TernaryState.True;
else if (countOfState > 0)
item.State.Value = TernaryState.Indeterminate;
else
item.State.Value = TernaryState.False;
return item;
}
}
}