1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-31 04:32:57 +08:00

Implement precise movement tool

As mentioned in one of the points in
https://github.com/ppy/osu/discussions/31263.
This commit is contained in:
Bartłomiej Dach 2025-01-17 13:33:59 +01:00
parent a8456ce9ac
commit ebca2e4b4f
No known key found for this signature in database
5 changed files with 228 additions and 3 deletions

View File

@ -0,0 +1,190 @@
// 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.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Input.Events;
using osu.Game.Extensions;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
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;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit
{
public partial class PreciseMovementPopover : OsuPopover
{
[Resolved]
private EditorBeatmap editorBeatmap { get; set; } = null!;
private readonly Dictionary<HitObject, Vector2> initialPositions = new Dictionary<HitObject, Vector2>();
private RectangleF initialSurroundingQuad;
private BindableNumber<float> xBindable = null!;
private BindableNumber<float> yBindable = null!;
private SliderWithTextBoxInput<float> xInput = null!;
private OsuCheckbox relativeCheckbox = null!;
public PreciseMovementPopover()
{
AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
}
[BackgroundDependencyLoader]
private void load()
{
Child = new FillFlowContainer
{
Width = 220,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(20),
Children = new Drawable[]
{
xInput = new SliderWithTextBoxInput<float>("X:")
{
Current = xBindable = new BindableNumber<float>
{
Precision = 1,
},
Instantaneous = true,
TabbableContentContainer = this,
},
new SliderWithTextBoxInput<float>("Y:")
{
Current = yBindable = new BindableNumber<float>
{
Precision = 1,
},
Instantaneous = true,
TabbableContentContainer = this,
},
relativeCheckbox = new OsuCheckbox(false)
{
RelativeSizeAxes = Axes.X,
LabelText = "Relative movement",
}
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
ScheduleAfterChildren(() =>
{
xInput.TakeFocus();
xInput.SelectAll();
});
}
protected override void PopIn()
{
base.PopIn();
editorBeatmap.BeginChange();
initialPositions.AddRange(editorBeatmap.SelectedHitObjects.Where(ho => ho is not Spinner).Select(ho => new KeyValuePair<HitObject, Vector2>(ho, ((IHasPosition)ho).Position)));
initialSurroundingQuad = GeometryUtils.GetSurroundingQuad(initialPositions.Keys.Cast<IHasPosition>()).AABBFloat;
Debug.Assert(initialPositions.Count > 0);
if (initialPositions.Count > 1)
{
relativeCheckbox.Current.Value = true;
relativeCheckbox.Current.Disabled = true;
}
relativeCheckbox.Current.BindValueChanged(_ => relativeChanged(), true);
xBindable.BindValueChanged(_ => applyPosition());
yBindable.BindValueChanged(_ => applyPosition());
}
protected override void PopOut()
{
base.PopOut();
if (IsLoaded) editorBeatmap.EndChange();
}
private void relativeChanged()
{
// reset bindable bounds to something that is guaranteed to be larger than any previous value.
// this prevents crashes that can happen in the middle of changing the bounds, as updating both bound ends at the same is not atomic -
// if the old and new bounds are disjoint, assigning X first can produce a situation where MinValue > MaxValue.
(xBindable.MinValue, xBindable.MaxValue) = (float.MinValue, float.MaxValue);
(yBindable.MinValue, yBindable.MaxValue) = (float.MinValue, float.MaxValue);
float previousX = xBindable.Value;
float previousY = yBindable.Value;
if (relativeCheckbox.Current.Value)
{
(xBindable.MinValue, xBindable.MaxValue) = (0 - initialSurroundingQuad.TopLeft.X, OsuPlayfield.BASE_SIZE.X - initialSurroundingQuad.BottomRight.X);
(yBindable.MinValue, yBindable.MaxValue) = (0 - initialSurroundingQuad.TopLeft.Y, OsuPlayfield.BASE_SIZE.Y - initialSurroundingQuad.BottomRight.Y);
xBindable.Default = yBindable.Default = 0;
if (initialPositions.Count == 1)
{
var initialPosition = initialPositions.Single().Value;
xBindable.Value = previousX - initialPosition.X;
yBindable.Value = previousY - initialPosition.Y;
}
}
else
{
Debug.Assert(initialPositions.Count == 1);
var initialPosition = initialPositions.Single().Value;
var quadRelativeToPosition = new RectangleF(initialSurroundingQuad.Location - initialPosition, initialSurroundingQuad.Size);
(xBindable.MinValue, xBindable.MaxValue) = (0 - quadRelativeToPosition.TopLeft.X, OsuPlayfield.BASE_SIZE.X - quadRelativeToPosition.BottomRight.X);
(yBindable.MinValue, yBindable.MaxValue) = (0 - quadRelativeToPosition.TopLeft.Y, OsuPlayfield.BASE_SIZE.Y - quadRelativeToPosition.BottomRight.Y);
xBindable.Default = initialPosition.X;
yBindable.Default = initialPosition.Y;
xBindable.Value = xBindable.Default + previousX;
yBindable.Value = yBindable.Default + previousY;
}
}
private void applyPosition()
{
editorBeatmap.PerformOnSelection(ho =>
{
if (!initialPositions.TryGetValue(ho, out var initialPosition))
return;
var pos = new Vector2(xBindable.Value, yBindable.Value);
if (relativeCheckbox.Current.Value)
((IHasPosition)ho).Position = initialPosition + pos;
else
((IHasPosition)ho).Position = pos;
});
}
public override bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
if (e.Action == GlobalAction.Select && !e.Repeat)
{
this.HidePopover();
return true;
}
return base.OnPressed(e);
}
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -10,6 +11,9 @@ using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Input.Bindings; using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components;
using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components;
using osuTK; using osuTK;
@ -18,9 +22,12 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
public partial class TransformToolboxGroup : EditorToolboxGroup, IKeyBindingHandler<GlobalAction> public partial class TransformToolboxGroup : EditorToolboxGroup, IKeyBindingHandler<GlobalAction>
{ {
private readonly BindableList<HitObject> selectedHitObjects = new BindableList<HitObject>();
private readonly BindableBool canMove = new BindableBool();
private readonly AggregateBindable<bool> canRotate = new AggregateBindable<bool>((x, y) => x || y); private readonly AggregateBindable<bool> canRotate = new AggregateBindable<bool>((x, y) => x || y);
private readonly AggregateBindable<bool> canScale = new AggregateBindable<bool>((x, y) => x || y); private readonly AggregateBindable<bool> canScale = new AggregateBindable<bool>((x, y) => x || y);
private EditorToolButton moveButton = null!;
private EditorToolButton rotateButton = null!; private EditorToolButton rotateButton = null!;
private EditorToolButton scaleButton = null!; private EditorToolButton scaleButton = null!;
@ -35,7 +42,7 @@ namespace osu.Game.Rulesets.Osu.Edit
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load(EditorBeatmap editorBeatmap)
{ {
Child = new FillFlowContainer Child = new FillFlowContainer
{ {
@ -44,20 +51,27 @@ namespace osu.Game.Rulesets.Osu.Edit
Spacing = new Vector2(5), Spacing = new Vector2(5),
Children = new Drawable[] Children = new Drawable[]
{ {
moveButton = new EditorToolButton("Move",
() => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt },
() => new PreciseMovementPopover()),
rotateButton = new EditorToolButton("Rotate", rotateButton = new EditorToolButton("Rotate",
() => new SpriteIcon { Icon = FontAwesome.Solid.Undo }, () => new SpriteIcon { Icon = FontAwesome.Solid.Undo },
() => new PreciseRotationPopover(RotationHandler, GridToolbox)), () => new PreciseRotationPopover(RotationHandler, GridToolbox)),
scaleButton = new EditorToolButton("Scale", scaleButton = new EditorToolButton("Scale",
() => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt }, () => new SpriteIcon { Icon = FontAwesome.Solid.ExpandArrowsAlt },
() => new PreciseScalePopover(ScaleHandler, GridToolbox)) () => new PreciseScalePopover(ScaleHandler, GridToolbox))
} }
}; };
selectedHitObjects.BindTo(editorBeatmap.SelectedHitObjects);
} }
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
selectedHitObjects.BindCollectionChanged((_, _) => canMove.Value = selectedHitObjects.Any(ho => ho is not Spinner), true);
canRotate.AddSource(RotationHandler.CanRotateAroundPlayfieldOrigin); canRotate.AddSource(RotationHandler.CanRotateAroundPlayfieldOrigin);
canRotate.AddSource(RotationHandler.CanRotateAroundSelectionOrigin); canRotate.AddSource(RotationHandler.CanRotateAroundSelectionOrigin);
@ -67,6 +81,7 @@ namespace osu.Game.Rulesets.Osu.Edit
// bindings to `Enabled` on the buttons are decoupled on purpose // bindings to `Enabled` on the buttons are decoupled on purpose
// due to the weird `OsuButton` behaviour of resetting `Enabled` to `false` when `Action` is set. // due to the weird `OsuButton` behaviour of resetting `Enabled` to `false` when `Action` is set.
canMove.BindValueChanged(move => moveButton.Enabled.Value = move.NewValue, true);
canRotate.Result.BindValueChanged(rotate => rotateButton.Enabled.Value = rotate.NewValue, true); canRotate.Result.BindValueChanged(rotate => rotateButton.Enabled.Value = rotate.NewValue, true);
canScale.Result.BindValueChanged(scale => scaleButton.Enabled.Value = scale.NewValue, true); canScale.Result.BindValueChanged(scale => scaleButton.Enabled.Value = scale.NewValue, true);
} }
@ -77,6 +92,12 @@ namespace osu.Game.Rulesets.Osu.Edit
switch (e.Action) switch (e.Action)
{ {
case GlobalAction.EditorToggleMoveControl:
{
moveButton.TriggerClick();
return true;
}
case GlobalAction.EditorToggleRotateControl: case GlobalAction.EditorToggleRotateControl:
{ {
if (!RotationHandler.OperationInProgress.Value || rotateButton.Selected.Value) if (!RotationHandler.OperationInProgress.Value || rotateButton.Selected.Value)

View File

@ -32,6 +32,11 @@ namespace osu.Game.Graphics.UserInterfaceV2
set => slider.Current = value; set => slider.Current = value;
} }
public CompositeDrawable TabbableContentContainer
{
set => textBox.TabbableContentContainer = value;
}
private bool instantaneous; private bool instantaneous;
/// <summary> /// <summary>

View File

@ -144,6 +144,7 @@ namespace osu.Game.Input.Bindings
new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelUp }, GlobalAction.EditorIncreaseDistanceSpacing), new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelUp }, GlobalAction.EditorIncreaseDistanceSpacing),
new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelDown }, GlobalAction.EditorCyclePreviousBeatSnapDivisor), new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelDown }, GlobalAction.EditorCyclePreviousBeatSnapDivisor),
new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelUp }, GlobalAction.EditorCycleNextBeatSnapDivisor), new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelUp }, GlobalAction.EditorCycleNextBeatSnapDivisor),
new KeyBinding(InputKey.None, GlobalAction.EditorToggleMoveControl),
new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.EditorToggleRotateControl), new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.EditorToggleRotateControl),
new KeyBinding(new[] { InputKey.Control, InputKey.E }, GlobalAction.EditorToggleScaleControl), new KeyBinding(new[] { InputKey.Control, InputKey.E }, GlobalAction.EditorToggleScaleControl),
new KeyBinding(new[] { InputKey.Control, InputKey.Left }, GlobalAction.EditorSeekToPreviousHitObject), new KeyBinding(new[] { InputKey.Control, InputKey.Left }, GlobalAction.EditorSeekToPreviousHitObject),
@ -493,7 +494,10 @@ namespace osu.Game.Input.Bindings
EditorSeekToNextBookmark, EditorSeekToNextBookmark,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.AbsoluteScrollSongList))] [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.AbsoluteScrollSongList))]
AbsoluteScrollSongList AbsoluteScrollSongList,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleMoveControl))]
EditorToggleMoveControl,
} }
public enum GlobalActionCategory public enum GlobalActionCategory

View File

@ -454,6 +454,11 @@ namespace osu.Game.Localisation
/// </summary> /// </summary>
public static LocalisableString AbsoluteScrollSongList => new TranslatableString(getKey(@"absolute_scroll_song_list"), @"Absolute scroll song list"); public static LocalisableString AbsoluteScrollSongList => new TranslatableString(getKey(@"absolute_scroll_song_list"), @"Absolute scroll song list");
/// <summary>
/// "Toggle movement control"
/// </summary>
public static LocalisableString EditorToggleMoveControl => new TranslatableString(getKey(@"editor_toggle_move_control"), @"Toggle movement control");
private static string getKey(string key) => $@"{prefix}:{key}"; private static string getKey(string key) => $@"{prefix}:{key}";
} }
} }