2019-01-24 16:43:03 +08:00
// 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.
2018-11-16 16:12:24 +08:00
2020-09-30 13:41:32 +08:00
using System.Collections.Generic ;
2018-11-16 16:12:24 +08:00
using System.Linq ;
2024-07-03 18:36:12 +08:00
using osu.Framework.Allocation ;
2020-09-29 18:43:50 +08:00
using osu.Framework.Graphics ;
2020-09-29 18:50:03 +08:00
using osu.Framework.Graphics.Primitives ;
2022-08-12 07:17:33 +08:00
using osu.Framework.Graphics.UserInterface ;
2022-08-15 23:18:55 +08:00
using osu.Framework.Input.Events ;
2020-09-30 12:02:05 +08:00
using osu.Framework.Utils ;
2021-04-29 14:29:25 +08:00
using osu.Game.Extensions ;
2022-08-12 07:17:33 +08:00
using osu.Game.Graphics.UserInterface ;
2022-01-07 22:11:38 +08:00
using osu.Game.Rulesets.Edit ;
2020-10-09 05:32:33 +08:00
using osu.Game.Rulesets.Objects ;
2020-09-29 19:00:19 +08:00
using osu.Game.Rulesets.Objects.Types ;
2024-07-11 19:22:36 +08:00
using osu.Game.Rulesets.Osu.Beatmaps ;
2018-11-16 16:12:24 +08:00
using osu.Game.Rulesets.Osu.Objects ;
2024-07-04 01:08:31 +08:00
using osu.Game.Rulesets.Osu.UI ;
2018-11-16 16:12:24 +08:00
using osu.Game.Screens.Edit.Compose.Components ;
2023-07-24 00:24:15 +08:00
using osu.Game.Utils ;
2021-05-20 17:21:16 +08:00
using osuTK ;
2022-08-15 23:18:55 +08:00
using osuTK.Input ;
2018-11-16 16:12:24 +08:00
namespace osu.Game.Rulesets.Osu.Edit
{
2022-11-24 13:32:20 +08:00
public partial class OsuSelectionHandler : EditorSelectionHandler
2018-11-16 16:12:24 +08:00
{
2023-12-30 08:38:08 +08:00
[Resolved]
private OsuGridToolboxGroup gridToolbox { get ; set ; } = null ! ;
2020-09-30 12:52:57 +08:00
protected override void OnSelectionChanged ( )
{
base . OnSelectionChanged ( ) ;
2020-09-29 18:43:50 +08:00
2023-07-24 00:24:15 +08:00
Quad quad = selectedMovableObjects . Length > 0 ? GeometryUtils . GetSurroundingQuad ( selectedMovableObjects ) : new Quad ( ) ;
2020-09-30 12:52:57 +08:00
2024-01-20 08:13:01 +08:00
SelectionBox . CanFlipX = quad . Width > 0 ;
SelectionBox . CanFlipY = quad . Height > 0 ;
2020-11-13 06:19:29 +08:00
SelectionBox . CanReverse = EditorBeatmap . SelectedHitObjects . Count > 1 | | EditorBeatmap . SelectedHitObjects . Any ( s = > s is Slider ) ;
2020-09-30 12:52:57 +08:00
}
2020-09-29 19:08:28 +08:00
2022-08-15 23:18:55 +08:00
protected override bool OnKeyDown ( KeyDownEvent e )
{
if ( e . Key = = Key . M & & e . ControlPressed & & e . ShiftPressed )
{
mergeSelection ( ) ;
return true ;
}
return false ;
}
2021-04-27 14:40:35 +08:00
public override bool HandleMovement ( MoveSelectionEvent < HitObject > moveEvent )
2021-02-22 00:38:50 +08:00
{
2021-02-24 03:58:46 +08:00
var hitObjects = selectedMovableObjects ;
2024-07-09 19:52:36 +08:00
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 ;
2021-03-30 13:13:16 +08:00
// this will potentially move the selection out of bounds...
2021-02-24 03:58:46 +08:00
foreach ( var h in hitObjects )
2024-07-09 19:52:36 +08:00
h . Position + = localDelta ;
2021-02-24 03:58:46 +08:00
2021-03-30 13:13:16 +08:00
// but this will be corrected.
2021-02-22 00:38:50 +08:00
moveSelectionInBounds ( ) ;
2024-07-09 19:52:36 +08:00
2024-07-11 19:22:36 +08:00
// 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 ) ;
2024-07-09 19:52:36 +08:00
2021-02-24 03:58:46 +08:00
return true ;
2021-02-22 00:38:50 +08:00
}
2020-09-29 19:08:28 +08:00
2020-10-09 05:32:33 +08:00
public override bool HandleReverse ( )
{
2024-03-26 23:10:40 +08:00
var hitObjects = EditorBeatmap . SelectedHitObjects
2024-03-28 17:12:27 +08:00
. OfType < OsuHitObject > ( )
. OrderBy ( obj = > obj . StartTime )
. ToList ( ) ;
2020-10-09 05:32:33 +08:00
double endTime = hitObjects . Max ( h = > h . GetEndTime ( ) ) ;
double startTime = hitObjects . Min ( h = > h . StartTime ) ;
2020-11-13 07:36:47 +08:00
bool moreThanOneObject = hitObjects . Count > 1 ;
2020-10-09 05:32:33 +08:00
2024-03-28 17:12:27 +08: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.
2024-03-26 23:10:40 +08:00
var newComboOrder = hitObjects . Select ( obj = > obj . NewCombo ) . ToList ( ) ;
2024-03-26 15:59:47 +08:00
2020-10-09 05:32:33 +08:00
foreach ( var h in hitObjects )
{
if ( moreThanOneObject )
h . StartTime = endTime - ( h . GetEndTime ( ) - startTime ) ;
if ( h is Slider slider )
{
2021-07-22 15:14:43 +08:00
slider . Path . Reverse ( out Vector2 offset ) ;
slider . Position + = offset ;
2020-10-09 05:32:33 +08:00
}
}
2024-03-28 17:12:27 +08: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-26 23:10:40 +08:00
2024-03-28 17:12:27 +08:00
for ( int i = 0 ; i < hitObjects . Count ; + + i )
hitObjects [ i ] . NewCombo = newComboOrder [ i ] ;
2024-03-25 15:19:14 +08:00
2020-10-09 05:32:33 +08:00
return true ;
}
2022-01-05 15:46:34 +08:00
public override bool HandleFlip ( Direction direction , bool flipOverOrigin )
2020-10-01 15:24:50 +08:00
{
var hitObjects = selectedMovableObjects ;
2024-01-01 04:23:13 +08:00
// If we're flipping over the origin, we take the grid origin position from the grid toolbox.
2023-12-30 08:38:08 +08:00
var flipQuad = flipOverOrigin ? new Quad ( gridToolbox . StartPositionX . Value , gridToolbox . StartPositionY . Value , 0 , 0 ) : GeometryUtils . GetSurroundingQuad ( hitObjects ) ;
2024-07-15 00:12:55 +08: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 20:21:42 +08:00
flipAxis = GeometryUtils . RotateVector ( Vector2 . UnitX , - ( ( gridToolbox . GridLinesRotation . Value + 360 + 45 ) % 90 - 45 ) ) ;
2024-07-15 00:12:55 +08: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 20:21:42 +08:00
// Angle degree range for vertical = (-120, -60]
// Angle degree range for horizontal = [-30, 30)
2024-07-15 00:12:55 +08:00
flipAxis = direction = = Direction . Vertical
2024-08-13 20:21:42 +08:00
? GeometryUtils . RotateVector ( Vector2 . UnitX , - ( ( gridToolbox . GridLinesRotation . Value + 360 + 30 ) % 60 + 60 ) )
2024-07-16 19:19:01 +08:00
: GeometryUtils . RotateVector ( Vector2 . UnitX , - ( ( gridToolbox . GridLinesRotation . Value + 360 ) % 60 - 30 ) ) ;
2024-07-15 00:12:55 +08:00
break ;
}
}
2024-01-01 04:23:13 +08:00
2023-12-30 08:38:08 +08:00
var controlPointFlipQuad = new Quad ( ) ;
2020-10-01 15:24:50 +08:00
2022-01-06 13:37:13 +08:00
bool didFlip = false ;
2020-10-01 15:24:50 +08:00
foreach ( var h in hitObjects )
{
2023-12-30 08:38:08 +08:00
var flippedPosition = GeometryUtils . GetFlippedPosition ( flipAxis , flipQuad , h . Position ) ;
2022-01-06 13:37:13 +08:00
2024-07-04 01:08:31 +08: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 ) ;
2022-01-06 13:37:13 +08:00
if ( ! Precision . AlmostEquals ( flippedPosition , h . Position ) )
{
h . Position = flippedPosition ;
didFlip = true ;
}
2020-10-01 15:24:50 +08:00
if ( h is Slider slider )
{
2022-01-06 13:37:13 +08:00
didFlip = true ;
2022-09-15 18:55:18 +08:00
foreach ( var cp in slider . Path . ControlPoints )
2023-12-30 08:38:08 +08:00
cp . Position = GeometryUtils . GetFlippedPosition ( flipAxis , controlPointFlipQuad , cp . Position ) ;
2020-10-01 15:24:50 +08:00
}
}
2022-01-06 13:37:13 +08:00
return didFlip ;
2020-10-01 15:24:50 +08:00
}
2023-07-31 01:39:30 +08:00
public override SelectionRotationHandler CreateRotationHandler ( ) = > new OsuSelectionRotationHandler ( ) ;
2020-09-30 14:08:56 +08:00
2024-01-20 07:22:53 +08:00
public override SelectionScaleHandler CreateScaleHandler ( ) = > new OsuSelectionScaleHandler ( ) ;
2021-02-22 00:40:57 +08:00
2021-02-21 04:48:31 +08:00
private void moveSelectionInBounds ( )
2018-11-16 16:12:24 +08:00
{
2020-09-30 13:41:32 +08:00
var hitObjects = selectedMovableObjects ;
2019-11-06 16:27:41 +08:00
2023-07-24 00:24:15 +08:00
Quad quad = GeometryUtils . GetSurroundingQuad ( hitObjects ) ;
2019-11-06 16:27:41 +08:00
2021-03-30 13:13:16 +08:00
Vector2 delta = Vector2 . Zero ;
2019-11-06 16:27:41 +08:00
2021-02-21 04:48:31 +08:00
if ( quad . TopLeft . X < 0 )
delta . X - = quad . TopLeft . X ;
if ( quad . TopLeft . Y < 0 )
delta . Y - = quad . TopLeft . Y ;
2020-11-15 21:22:46 +08:00
2021-02-21 04:48:31 +08:00
if ( quad . BottomRight . X > DrawWidth )
delta . X - = quad . BottomRight . X - DrawWidth ;
if ( quad . BottomRight . Y > DrawHeight )
delta . Y - = quad . BottomRight . Y - DrawHeight ;
2019-11-06 16:27:41 +08:00
2020-09-30 13:41:32 +08:00
foreach ( var h in hitObjects )
2020-09-29 18:43:50 +08:00
h . Position + = delta ;
2018-11-16 16:12:24 +08:00
}
2018-11-26 15:08:56 +08:00
2020-09-30 13:41:32 +08:00
/// <summary>
/// All osu! hitobjects which can be moved/rotated/scaled.
/// </summary>
2021-04-27 17:03:34 +08:00
private OsuHitObject [ ] selectedMovableObjects = > SelectedItems . OfType < OsuHitObject > ( )
2022-08-16 14:37:38 +08:00
. Where ( h = > h is not Spinner )
2020-10-09 17:50:05 +08:00
. ToArray ( ) ;
2022-08-12 07:17:33 +08: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 ( )
{
2022-08-16 14:38:13 +08:00
var mergeableObjects = selectedMergeableObjects ;
2022-08-30 00:58:29 +08:00
if ( ! canMerge ( mergeableObjects ) )
2022-08-12 07:17:33 +08:00
return ;
2022-08-27 23:43:32 +08:00
EditorBeatmap . BeginChange ( ) ;
2022-08-12 07:17:33 +08:00
// Have an initial slider object.
2022-08-16 14:38:13 +08:00
var firstHitObject = mergeableObjects [ 0 ] ;
2022-08-12 07:17:33 +08:00
var mergedHitObject = firstHitObject as Slider ? ? new Slider
{
StartTime = firstHitObject . StartTime ,
Position = firstHitObject . Position ,
NewCombo = firstHitObject . NewCombo ,
2022-08-25 17:49:38 +08:00
Samples = firstHitObject . Samples ,
2022-08-12 07:17:33 +08:00
} ;
if ( mergedHitObject . Path . ControlPoints . Count = = 0 )
{
2023-11-08 18:43:54 +08:00
mergedHitObject . Path . ControlPoints . Add ( new PathControlPoint ( Vector2 . Zero , PathType . LINEAR ) ) ;
2022-08-12 07:17:33 +08:00
}
// Merge all the selected hit objects into one slider path.
bool lastCircle = firstHitObject is HitCircle ;
2022-08-16 14:38:13 +08:00
foreach ( var selectedMergeableObject in mergeableObjects . Skip ( 1 ) )
2022-08-12 07:17:33 +08: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 )
{
2023-11-08 18:43:54 +08:00
mergedHitObject . Path . ControlPoints . Last ( ) . Type = PathType . LINEAR ;
2022-08-12 07:17:33 +08: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 )
{
2022-08-16 14:38:13 +08:00
foreach ( var selectedMergeableObject in mergeableObjects . Skip ( 1 ) )
2022-08-12 07:17:33 +08:00
{
2022-08-16 14:35:32 +08:00
EditorBeatmap . Remove ( selectedMergeableObject ) ;
2022-08-12 07:17:33 +08:00
}
}
else
{
2022-08-16 14:38:13 +08:00
foreach ( var selectedMergeableObject in mergeableObjects )
2022-08-12 07:17:33 +08:00
{
2022-08-16 14:35:32 +08:00
EditorBeatmap . Remove ( selectedMergeableObject ) ;
2022-08-12 07:17:33 +08:00
}
2022-08-16 14:35:32 +08:00
EditorBeatmap . Add ( mergedHitObject ) ;
2022-08-12 07:17:33 +08:00
}
// Make sure the merged hitobject is selected.
SelectedItems . Clear ( ) ;
SelectedItems . Add ( mergedHitObject ) ;
2022-08-27 23:43:32 +08:00
EditorBeatmap . EndChange ( ) ;
2022-08-12 07:17:33 +08:00
}
protected override IEnumerable < MenuItem > GetContextMenuItemsForSelection ( IEnumerable < SelectionBlueprint < HitObject > > selection )
{
foreach ( var item in base . GetContextMenuItemsForSelection ( selection ) )
yield return item ;
2022-08-30 00:58:29 +08:00
if ( canMerge ( selectedMergeableObjects ) )
2022-08-12 07:17:33 +08:00
yield return new OsuMenuItem ( "Merge selection" , MenuItemType . Destructive , mergeSelection ) ;
}
2022-08-30 01:51:42 +08:00
private bool canMerge ( IReadOnlyList < OsuHitObject > objects ) = >
objects . Count > 1
& & ( objects . Any ( h = > h is Slider )
2022-08-30 06:18:55 +08:00
| | objects . Zip ( objects . Skip ( 1 ) , ( h1 , h2 ) = > Precision . DefinitelyBigger ( Vector2 . DistanceSquared ( h1 . Position , h2 . Position ) , 1 ) ) . Any ( x = > x ) ) ;
2018-11-16 16:12:24 +08:00
}
}