diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs new file mode 100644 index 0000000000..47b2d3a098 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneOsuEditorGrids : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestGridExclusivity() + { + AddStep("enable distance snap grid", () => InputManager.Key(Key.T)); + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + rectangularGridActive(false); + + AddStep("enable rectangular grid", () => InputManager.Key(Key.Y)); + AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + rectangularGridActive(true); + + AddStep("enable distance snap grid", () => InputManager.Key(Key.T)); + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + rectangularGridActive(false); + } + + private void rectangularGridActive(bool active) + { + AddStep("choose placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move cursor to (1, 1)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(new Vector2(1, 1))); + }); + + if (active) + AddAssert("placement blueprint at (0, 0)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(0, 0))); + else + AddAssert("placement blueprint at (1, 1)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(1, 1))); + + AddStep("choose selection tool", () => InputManager.Key(Key.Number1)); + } + + [Test] + public void TestGridSizeToggling() + { + AddStep("enable rectangular grid", () => InputManager.Key(Key.Y)); + AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any()); + gridSizeIs(4); + + nextGridSizeIs(8); + nextGridSizeIs(16); + nextGridSizeIs(32); + nextGridSizeIs(4); + } + + private void nextGridSizeIs(int size) + { + AddStep("toggle to next grid size", () => InputManager.Key(Key.G)); + gridSizeIs(size); + } + + private void gridSizeIs(int size) + => AddAssert($"grid size is {size}", () => this.ChildrenOfType().Single().Spacing == new Vector2(size) + && EditorBeatmap.BeatmapInfo.GridSize == size); + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 806b7e6051..1e84ec80e1 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -42,10 +42,12 @@ namespace osu.Game.Rulesets.Osu.Edit }; private readonly Bindable distanceSnapToggle = new Bindable(); + private readonly Bindable rectangularGridSnapToggle = new Bindable(); protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] { - new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) + new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }), + new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th }) }); private BindableList selectedHitObjects; @@ -63,6 +65,10 @@ namespace osu.Game.Rulesets.Osu.Edit PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } }, distanceSnapGridContainer = new Container + { + RelativeSizeAxes = Axes.Both + }, + rectangularPositionSnapGrid = new OsuRectangularPositionSnapGrid { RelativeSizeAxes = Axes.Both } @@ -73,7 +79,19 @@ namespace osu.Game.Rulesets.Osu.Edit placementObject = EditorBeatmap.PlacementObject.GetBoundCopy(); placementObject.ValueChanged += _ => updateDistanceSnapGrid(); - distanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); + distanceSnapToggle.ValueChanged += _ => + { + updateDistanceSnapGrid(); + + if (distanceSnapToggle.Value == TernaryState.True) + rectangularGridSnapToggle.Value = TernaryState.False; + }; + + rectangularGridSnapToggle.ValueChanged += _ => + { + if (rectangularGridSnapToggle.Value == TernaryState.True) + distanceSnapToggle.Value = TernaryState.False; + }; // we may be entering the screen with a selection already active updateDistanceSnapGrid(); @@ -91,6 +109,8 @@ namespace osu.Game.Rulesets.Osu.Edit private readonly Cached distanceSnapGridCache = new Cached(); private double? lastDistanceSnapGridTime; + private RectangularPositionSnapGrid rectangularPositionSnapGrid; + protected override void Update() { base.Update(); @@ -122,13 +142,19 @@ namespace osu.Game.Rulesets.Osu.Edit if (positionSnap.ScreenSpacePosition != screenSpacePosition) return positionSnap; - // will be null if distance snap is disabled or not feasible for the current time value. - if (distanceSnapGrid == null) - return base.SnapScreenSpacePositionToValidTime(screenSpacePosition); + if (distanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + { + (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); + return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + } - (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); + if (rectangularGridSnapToggle.Value == TernaryState.True) + { + Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(screenSpacePosition)); + return new SnapResult(rectangularPositionSnapGrid.ToScreenSpace(pos), null, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + } - return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + return base.SnapScreenSpacePositionToValidTime(screenSpacePosition); } private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs b/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs new file mode 100644 index 0000000000..b8ff92bd37 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs @@ -0,0 +1,69 @@ +// 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.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public class OsuRectangularPositionSnapGrid : RectangularPositionSnapGrid, IKeyBindingHandler + { + private static readonly int[] grid_sizes = { 4, 8, 16, 32 }; + + private int currentGridSizeIndex = grid_sizes.Length - 1; + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } + + public OsuRectangularPositionSnapGrid() + : base(OsuPlayfield.BASE_SIZE / 2) + { + } + + [BackgroundDependencyLoader] + private void load() + { + var gridSizeIndex = Array.IndexOf(grid_sizes, editorBeatmap.BeatmapInfo.GridSize); + if (gridSizeIndex >= 0) + currentGridSizeIndex = gridSizeIndex; + updateSpacing(); + } + + private void nextGridSize() + { + currentGridSizeIndex = (currentGridSizeIndex + 1) % grid_sizes.Length; + updateSpacing(); + } + + private void updateSpacing() + { + int gridSize = grid_sizes[currentGridSizeIndex]; + + editorBeatmap.BeatmapInfo.GridSize = gridSize; + Spacing = new Vector2(gridSize); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.EditorCycleGridDisplayMode: + nextGridSize(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs new file mode 100644 index 0000000000..85a98eca47 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs @@ -0,0 +1,104 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneRectangularPositionSnapGrid : OsuManualInputManagerTestScene + { + private Container content; + protected override Container Content => content; + + [BackgroundDependencyLoader] + private void load() + { + base.Content.AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Gray + }, + content = new Container + { + RelativeSizeAxes = Axes.Both + } + }); + } + + private static readonly object[][] test_cases = + { + new object[] { new Vector2(0, 0), new Vector2(10, 10) }, + new object[] { new Vector2(240, 180), new Vector2(10, 15) }, + new object[] { new Vector2(160, 120), new Vector2(30, 20) }, + new object[] { new Vector2(480, 360), new Vector2(100, 100) }, + }; + + [TestCaseSource(nameof(test_cases))] + public void TestRectangularGrid(Vector2 position, Vector2 spacing) + { + RectangularPositionSnapGrid grid = null; + + AddStep("create grid", () => Child = grid = new RectangularPositionSnapGrid(position) + { + RelativeSizeAxes = Axes.Both, + Spacing = spacing + }); + + AddStep("add snapping cursor", () => Add(new SnappingCursorContainer + { + RelativeSizeAxes = Axes.Both, + GetSnapPosition = pos => grid.GetSnappedPosition(grid.ToLocalSpace(pos)) + })); + } + + private class SnappingCursorContainer : CompositeDrawable + { + public Func GetSnapPosition; + + private readonly Drawable cursor; + + public SnappingCursorContainer() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = cursor = new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(50), + Colour = Color4.Red + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updatePosition(GetContainingInputManager().CurrentState.Mouse.Position); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + base.OnMouseMove(e); + + updatePosition(e.ScreenSpaceMousePosition); + return true; + } + + private void updatePosition(Vector2 screenSpacePosition) + { + cursor.Position = GetSnapPosition.Invoke(screenSpacePosition); + } + } + } +} diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index f62131e2d7..9fd7caadd0 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -75,6 +75,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.A }, GlobalAction.EditorVerifyMode), new KeyBinding(new[] { InputKey.J }, GlobalAction.EditorNudgeLeft), new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight), + new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridDisplayMode), }; public IEnumerable InGameKeyBindings => new[] @@ -284,6 +285,9 @@ namespace osu.Game.Input.Bindings SeekReplayBackward, [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChatFocus))] - ToggleChatFocus + ToggleChatFocus, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridDisplayMode))] + EditorCycleGridDisplayMode } } diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 14159f0d34..06f1b094bf 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -164,6 +164,11 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorTimingMode => new TranslatableString(getKey(@"editor_timing_mode"), @"Timing mode"); + /// + /// "Cycle grid display mode" + /// + public static LocalisableString EditorCycleGridDisplayMode => new TranslatableString(getKey(@"editor_cycle_grid_display_mode"), @"Cycle grid display mode"); + /// /// "Hold for HUD" /// diff --git a/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs new file mode 100644 index 0000000000..95b4b2fe53 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs @@ -0,0 +1,113 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Layout; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public class RectangularPositionSnapGrid : CompositeDrawable + { + /// + /// The position of the origin of this in local coordinates. + /// + public Vector2 StartPosition { get; } + + private Vector2 spacing = Vector2.One; + + /// + /// The spacing between grid lines of this . + /// + public Vector2 Spacing + { + get => spacing; + set + { + if (spacing.X <= 0 || spacing.Y <= 0) + throw new ArgumentException("Grid spacing must be positive."); + + spacing = value; + gridCache.Invalidate(); + } + } + + private readonly LayoutValue gridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); + + public RectangularPositionSnapGrid(Vector2 startPosition) + { + StartPosition = startPosition; + + AddLayout(gridCache); + } + + protected override void Update() + { + base.Update(); + + if (!gridCache.IsValid) + { + ClearInternal(); + createContent(); + gridCache.Validate(); + } + } + + private void createContent() + { + var drawSize = DrawSize; + + generateGridLines(Direction.Horizontal, StartPosition.Y, 0, -Spacing.Y); + generateGridLines(Direction.Horizontal, StartPosition.Y, drawSize.Y, Spacing.Y); + + generateGridLines(Direction.Vertical, StartPosition.X, 0, -Spacing.X); + generateGridLines(Direction.Vertical, StartPosition.X, drawSize.X, Spacing.X); + } + + private void generateGridLines(Direction direction, float startPosition, float endPosition, float step) + { + int index = 0; + float currentPosition = startPosition; + + while ((endPosition - currentPosition) * Math.Sign(step) > 0) + { + var gridLine = new Box + { + Colour = Colour4.White, + Alpha = index == 0 ? 0.3f : 0.1f, + EdgeSmoothness = new Vector2(0.2f) + }; + + if (direction == Direction.Horizontal) + { + gridLine.RelativeSizeAxes = Axes.X; + gridLine.Height = 1; + gridLine.Y = currentPosition; + } + else + { + gridLine.RelativeSizeAxes = Axes.Y; + gridLine.Width = 1; + gridLine.X = currentPosition; + } + + AddInternal(gridLine); + + index += 1; + currentPosition = startPosition + index * step; + } + } + + public Vector2 GetSnappedPosition(Vector2 original) + { + Vector2 relativeToStart = original - StartPosition; + Vector2 offset = Vector2.Divide(relativeToStart, Spacing); + Vector2 roundedOffset = new Vector2(MathF.Round(offset.X), MathF.Round(offset.Y)); + + return StartPosition + Vector2.Multiply(roundedOffset, Spacing); + } + } +}