osu/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

327 lines
14 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.
using System.Collections.Generic;
using System.Linq;
2024-07-03 10:36:12 +00:00
using osu.Framework.Allocation;
2020-09-29 10:43:50 +00:00
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
2022-08-11 23:17:33 +00:00
using osu.Framework.Graphics.UserInterface;
2022-08-15 15:18:55 +00:00
using osu.Framework.Input.Events;
2020-09-30 04:02:05 +00:00
using osu.Framework.Utils;
using osu.Game.Extensions;
2022-08-11 23:17:33 +00:00
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
2020-10-08 21:32:33 +00:00
using osu.Game.Rulesets.Objects;
2020-09-29 11:00:19 +00:00
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
2024-07-03 17:08:31 +00:00
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Utils;
using osuTK;
2022-08-15 15:18:55 +00:00
using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Edit
{
public partial class OsuSelectionHandler : EditorSelectionHandler
{
2023-12-30 00:38:08 +00:00
[Resolved]
private OsuGridToolboxGroup gridToolbox { get; set; } = null!;
protected override void OnSelectionChanged()
{
base.OnSelectionChanged();
2020-09-29 10:43:50 +00:00
Quad quad = selectedMovableObjects.Length > 0 ? GeometryUtils.GetSurroundingQuad(selectedMovableObjects) : new Quad();
2024-01-20 00:13:01 +00:00
SelectionBox.CanFlipX = quad.Width > 0;
SelectionBox.CanFlipY = quad.Height > 0;
SelectionBox.CanReverse = EditorBeatmap.SelectedHitObjects.Count > 1 || EditorBeatmap.SelectedHitObjects.Any(s => s is Slider);
}
2020-09-29 11:08:28 +00:00
2022-08-15 15:18:55 +00:00
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)
{
2021-02-23 19:58:46 +00:00
var hitObjects = selectedMovableObjects;
var localDelta = this.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta);
// this conditional is a rather ugly special case for stacks.
// as it turns out, adding the `EditorBeatmap.Update()` call at the end of this would cause stacked objects to jitter when moved around
// (they would stack and then unstack every frame).
// the reason for that is that the selection handling abstractions are not aware of the distinction between "displayed" and "actual" position
// which is unique to osu! due to stacking being applied as a post-processing step.
// therefore, the following loop would occur:
// - on frame 1 the blueprint is snapped to the stack's baseline position. `EditorBeatmap.Update()` applies stacking successfully,
// the blueprint moves up the stack from its original drag position.
// - on frame 2 the blueprint's position is now the *stacked* position, which is interpreted higher up as *manually performing an unstack*
// to the blueprint's unstacked position (as the machinery higher up only cares about differences in screen space position).
if (hitObjects.Any(h => Precision.AlmostEquals(localDelta, -h.StackOffset)))
return true;
// this will potentially move the selection out of bounds...
2021-02-23 19:58:46 +00:00
foreach (var h in hitObjects)
h.Position += localDelta;
2021-02-23 19:58:46 +00:00
// but this will be corrected.
moveSelectionInBounds();
// manually update stacking.
// this intentionally bypasses the editor `UpdateState()` / beatmap processor flow for performance reasons,
// as the entire flow is too expensive to run on every movement.
Scheduler.AddOnce(OsuBeatmapProcessor.ApplyStacking, EditorBeatmap);
2021-02-23 19:58:46 +00:00
return true;
}
2020-09-29 11:08:28 +00:00
2020-10-08 21:32:33 +00:00
public override bool HandleReverse()
{
var hitObjects = EditorBeatmap.SelectedHitObjects
2024-03-28 09:12:27 +00:00
.OfType<OsuHitObject>()
.OrderBy(obj => obj.StartTime)
.ToList();
2020-10-08 21:32:33 +00:00
double endTime = hitObjects.Max(h => h.GetEndTime());
double startTime = hitObjects.Min(h => h.StartTime);
2020-11-12 23:36:47 +00:00
bool moreThanOneObject = hitObjects.Count > 1;
2020-10-08 21:32:33 +00:00
2024-03-28 09:12:27 +00:00
// the expectation is that even if the objects themselves are reversed temporally,
// the position of new combos in the selection should remain the same.
// preserve it for later before doing the reversal.
var newComboOrder = hitObjects.Select(obj => obj.NewCombo).ToList();
2020-10-08 21:32:33 +00:00
foreach (var h in hitObjects)
{
if (moreThanOneObject)
h.StartTime = endTime - (h.GetEndTime() - startTime);
if (h is Slider slider)
{
slider.Path.Reverse(out Vector2 offset);
slider.Position += offset;
2020-10-08 21:32:33 +00:00
}
}
2024-03-28 09:12:27 +00:00
// re-order objects by start time again after reversing, and restore new combo flag positioning
hitObjects = hitObjects.OrderBy(obj => obj.StartTime).ToList();
2024-03-28 09:12:27 +00:00
for (int i = 0; i < hitObjects.Count; ++i)
hitObjects[i].NewCombo = newComboOrder[i];
2024-03-25 07:19:14 +00:00
2020-10-08 21:32:33 +00:00
return true;
}
2022-01-05 07:46:34 +00:00
public override bool HandleFlip(Direction direction, bool flipOverOrigin)
2020-10-01 07:24:50 +00:00
{
var hitObjects = selectedMovableObjects;
// If we're flipping over the origin, we take the grid origin position from the grid toolbox.
2023-12-30 00:38:08 +00:00
var flipQuad = flipOverOrigin ? new Quad(gridToolbox.StartPositionX.Value, gridToolbox.StartPositionY.Value, 0, 0) : GeometryUtils.GetSurroundingQuad(hitObjects);
2024-07-14 16:12:55 +00:00
Vector2 flipAxis = direction == Direction.Vertical ? Vector2.UnitY : Vector2.UnitX;
if (flipOverOrigin)
{
// If we're flipping over the origin, we take one of the axes of the grid.
// Take the axis closest to the direction we want to flip over.
switch (gridToolbox.GridType.Value)
{
case PositionSnapGridType.Square:
2024-08-13 12:21:42 +00:00
flipAxis = GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360 + 45) % 90 - 45));
2024-07-14 16:12:55 +00:00
flipAxis = direction == Direction.Vertical ? flipAxis.PerpendicularLeft : flipAxis;
break;
case PositionSnapGridType.Triangle:
// Hex grid has 3 axes, so you can not directly flip over one of the axes,
// however it's still possible to achieve that flip by combining multiple flips over the other axes.
2024-08-13 12:21:42 +00:00
// Angle degree range for vertical = (-120, -60]
// Angle degree range for horizontal = [-30, 30)
2024-07-14 16:12:55 +00:00
flipAxis = direction == Direction.Vertical
2024-08-13 12:21:42 +00:00
? GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360 + 30) % 60 + 60))
2024-07-16 11:19:01 +00:00
: GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360) % 60 - 30));
2024-07-14 16:12:55 +00:00
break;
}
}
2023-12-30 00:38:08 +00:00
var controlPointFlipQuad = new Quad();
2020-10-01 07:24:50 +00:00
bool didFlip = false;
2020-10-01 07:24:50 +00:00
foreach (var h in hitObjects)
{
2023-12-30 00:38:08 +00:00
var flippedPosition = GeometryUtils.GetFlippedPosition(flipAxis, flipQuad, h.Position);
2024-07-03 17:08:31 +00:00
// Clamp the flipped position inside the playfield bounds, because the flipped position might be outside the playfield bounds if the origin is not centered.
flippedPosition = Vector2.Clamp(flippedPosition, Vector2.Zero, OsuPlayfield.BASE_SIZE);
if (!Precision.AlmostEquals(flippedPosition, h.Position))
{
h.Position = flippedPosition;
didFlip = true;
}
2020-10-01 07:24:50 +00:00
if (h is Slider slider)
{
didFlip = true;
2022-09-15 10:55:18 +00:00
foreach (var cp in slider.Path.ControlPoints)
2023-12-30 00:38:08 +00:00
cp.Position = GeometryUtils.GetFlippedPosition(flipAxis, controlPointFlipQuad, cp.Position);
2020-10-01 07:24:50 +00:00
}
}
return didFlip;
2020-10-01 07:24:50 +00:00
}
public override SelectionRotationHandler CreateRotationHandler() => new OsuSelectionRotationHandler();
public override SelectionScaleHandler CreateScaleHandler() => new OsuSelectionScaleHandler();
private void moveSelectionInBounds()
{
var hitObjects = selectedMovableObjects;
Quad quad = GeometryUtils.GetSurroundingQuad(hitObjects);
Vector2 delta = Vector2.Zero;
if (quad.TopLeft.X < 0)
delta.X -= quad.TopLeft.X;
if (quad.TopLeft.Y < 0)
delta.Y -= quad.TopLeft.Y;
if (quad.BottomRight.X > DrawWidth)
delta.X -= quad.BottomRight.X - DrawWidth;
if (quad.BottomRight.Y > DrawHeight)
delta.Y -= quad.BottomRight.Y - DrawHeight;
foreach (var h in hitObjects)
2020-09-29 10:43:50 +00:00
h.Position += delta;
}
/// <summary>
/// All osu! hitobjects which can be moved/rotated/scaled.
/// </summary>
private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType<OsuHitObject>()
.Where(h => h is not Spinner)
2020-10-09 09:50:05 +00:00
.ToArray();
2022-08-11 23:17:33 +00:00
/// <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 (!canMerge(mergeableObjects))
2022-08-11 23:17:33 +00:00
return;
2022-08-27 15:43:32 +00:00
EditorBeatmap.BeginChange();
2022-08-11 23:17:33 +00:00
// Have an initial slider object.
var firstHitObject = mergeableObjects[0];
2022-08-11 23:17:33 +00:00
var mergedHitObject = firstHitObject as Slider ?? new Slider
{
StartTime = firstHitObject.StartTime,
Position = firstHitObject.Position,
NewCombo = firstHitObject.NewCombo,
Samples = firstHitObject.Samples,
2022-08-11 23:17:33 +00:00
};
if (mergedHitObject.Path.ControlPoints.Count == 0)
{
mergedHitObject.Path.ControlPoints.Add(new PathControlPoint(Vector2.Zero, PathType.LINEAR));
2022-08-11 23:17:33 +00:00
}
// Merge all the selected hit objects into one slider path.
bool lastCircle = firstHitObject is HitCircle;
foreach (var selectedMergeableObject in mergeableObjects.Skip(1))
2022-08-11 23:17:33 +00:00
{
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;
2022-08-11 23:17:33 +00:00
}
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))
2022-08-11 23:17:33 +00:00
{
EditorBeatmap.Remove(selectedMergeableObject);
2022-08-11 23:17:33 +00:00
}
}
else
{
foreach (var selectedMergeableObject in mergeableObjects)
2022-08-11 23:17:33 +00:00
{
EditorBeatmap.Remove(selectedMergeableObject);
2022-08-11 23:17:33 +00:00
}
EditorBeatmap.Add(mergedHitObject);
2022-08-11 23:17:33 +00:00
}
// Make sure the merged hitobject is selected.
SelectedItems.Clear();
SelectedItems.Add(mergedHitObject);
2022-08-27 15:43:32 +00:00
EditorBeatmap.EndChange();
2022-08-11 23:17:33 +00:00
}
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<HitObject>> selection)
{
foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item;
if (canMerge(selectedMergeableObjects))
2022-08-11 23:17:33 +00:00
yield return new OsuMenuItem("Merge selection", MenuItemType.Destructive, mergeSelection);
}
2022-08-29 17:51:42 +00:00
private bool canMerge(IReadOnlyList<OsuHitObject> objects) =>
objects.Count > 1
&& (objects.Any(h => h is Slider)
|| objects.Zip(objects.Skip(1), (h1, h2) => Precision.DefinitelyBigger(Vector2.DistanceSquared(h1.Position, h2.Position), 1)).Any(x => x));
}
}