diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectComposer.cs new file mode 100644 index 0000000000..286e3f6e50 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectComposer.cs @@ -0,0 +1,174 @@ +// 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 System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Edit; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestSceneManiaHitObjectComposer : EditorClockTestScene + { + public override IReadOnlyList RequiredTypes => new[] + { + typeof(ManiaBlueprintContainer) + }; + + private TestComposer composer; + + [SetUp] + public void Setup() => Schedule(() => + { + BeatDivisor.Value = 8; + Clock.Seek(0); + + Child = composer = new TestComposer { RelativeSizeAxes = Axes.Both }; + }); + + [Test] + public void TestDragOffscreenSelectionVerticallyUpScroll() + { + DrawableHitObject lastObject = null; + Vector2 originalPosition = Vector2.Zero; + + AddStep("seek to last object", () => + { + lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); + Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + }); + + AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); + + AddStep("click last object", () => + { + originalPosition = lastObject.DrawPosition; + + InputManager.MoveMouseTo(lastObject); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("move mouse downwards", () => + { + InputManager.MoveMouseTo(lastObject, new Vector2(0, 20)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("hitobjects not moved columns", () => composer.EditorBeatmap.HitObjects.All(h => ((ManiaHitObject)h).Column == 0)); + AddAssert("hitobjects moved downwards", () => lastObject.DrawPosition.Y - originalPosition.Y > 0); + AddAssert("hitobjects not moved too far", () => lastObject.DrawPosition.Y - originalPosition.Y < 50); + } + + [Test] + public void TestDragOffscreenSelectionVerticallyDownScroll() + { + DrawableHitObject lastObject = null; + Vector2 originalPosition = Vector2.Zero; + + AddStep("set down scroll", () => ((Bindable)composer.Composer.ScrollingInfo.Direction).Value = ScrollingDirection.Down); + + AddStep("seek to last object", () => + { + lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); + Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + }); + + AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); + + AddStep("click last object", () => + { + originalPosition = lastObject.DrawPosition; + + InputManager.MoveMouseTo(lastObject); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("move mouse upwards", () => + { + InputManager.MoveMouseTo(lastObject, new Vector2(0, -20)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("hitobjects not moved columns", () => composer.EditorBeatmap.HitObjects.All(h => ((ManiaHitObject)h).Column == 0)); + AddAssert("hitobjects moved upwards", () => originalPosition.Y - lastObject.DrawPosition.Y > 0); + AddAssert("hitobjects not moved too far", () => originalPosition.Y - lastObject.DrawPosition.Y < 50); + } + + [Test] + public void TestDragOffscreenSelectionHorizontally() + { + DrawableHitObject lastObject = null; + Vector2 originalPosition = Vector2.Zero; + + AddStep("seek to last object", () => + { + lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); + Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + }); + + AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); + + AddStep("click last object", () => + { + originalPosition = lastObject.DrawPosition; + + InputManager.MoveMouseTo(lastObject); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("move mouse right", () => + { + var firstColumn = composer.Composer.Playfield.GetColumn(0); + var secondColumn = composer.Composer.Playfield.GetColumn(1); + + InputManager.MoveMouseTo(lastObject, new Vector2(secondColumn.ScreenSpaceDrawQuad.Centre.X - firstColumn.ScreenSpaceDrawQuad.Centre.X + 1, 0)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("hitobjects moved columns", () => composer.EditorBeatmap.HitObjects.All(h => ((ManiaHitObject)h).Column == 1)); + + // Todo: They'll move vertically by the height of a note since there's no snapping and the selection point is the middle of the note. + AddAssert("hitobjects not moved vertically", () => lastObject.DrawPosition.Y - originalPosition.Y <= DefaultNotePiece.NOTE_HEIGHT); + } + + private class TestComposer : CompositeDrawable + { + [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] + public readonly EditorBeatmap EditorBeatmap; + + public readonly ManiaHitObjectComposer Composer; + + public TestComposer() + { + InternalChildren = new Drawable[] + { + EditorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 })) + { + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo } + }, + Composer = new ManiaHitObjectComposer(new ManiaRuleset()) + }; + + for (int i = 0; i < 10; i++) + EditorBeatmap.Add(new Note { StartTime = 100 * i }); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs index d569d68b59..43d43ef252 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs @@ -76,5 +76,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints } public override Quad SelectionQuad => ScreenSpaceDrawQuad; + + public override Vector2 SelectionPoint => DrawableObject.Head.ScreenSpaceDrawQuad.Centre; } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs index 9f57160f99..b8574b804e 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs @@ -3,8 +3,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Input.Events; -using osu.Framework.Timing; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables; @@ -15,13 +13,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public class ManiaSelectionBlueprint : OverlaySelectionBlueprint { - public Vector2 ScreenSpaceDragPosition { get; private set; } - public Vector2 DragPosition { get; private set; } - public new DrawableManiaHitObject DrawableObject => (DrawableManiaHitObject)base.DrawableObject; - protected IClock EditorClock { get; private set; } - [Resolved] private IScrollingInfo scrollingInfo { get; set; } @@ -34,12 +27,6 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints RelativeSizeAxes = Axes.None; } - [BackgroundDependencyLoader] - private void load(IAdjustableClock clock) - { - EditorClock = clock; - } - protected override void Update() { base.Update(); @@ -47,22 +34,6 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints Position = Parent.ToLocalSpace(DrawableObject.ToScreenSpace(Vector2.Zero)); } - protected override bool OnMouseDown(MouseDownEvent e) - { - ScreenSpaceDragPosition = e.ScreenSpaceMousePosition; - DragPosition = DrawableObject.ToLocalSpace(e.ScreenSpaceMousePosition); - - return base.OnMouseDown(e); - } - - protected override void OnDrag(DragEvent e) - { - base.OnDrag(e); - - ScreenSpaceDragPosition = e.ScreenSpaceMousePosition; - DragPosition = DrawableObject.ToLocalSpace(e.ScreenSpaceMousePosition); - } - public override void Show() { DrawableObject.AlwaysAlive = true; diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs index d744036b4c..cea27498c3 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs @@ -30,5 +30,7 @@ namespace osu.Game.Rulesets.Mania.Edit return base.CreateBlueprintFor(hitObject); } + + protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index 62b609610f..dfa933baad 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -10,6 +10,7 @@ using osu.Framework.Allocation; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -37,7 +38,33 @@ namespace osu.Game.Rulesets.Mania.Edit protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - public int TotalColumns => ((ManiaPlayfield)drawableRuleset.Playfield).TotalColumns; + public ManiaPlayfield Playfield => ((ManiaPlayfield)drawableRuleset.Playfield); + + public IScrollingInfo ScrollingInfo => drawableRuleset.ScrollingInfo; + + public int TotalColumns => Playfield.TotalColumns; + + public override (Vector2 position, double time) GetSnappedPosition(Vector2 position, double time) + { + var hoc = Playfield.GetColumn(0).HitObjectContainer; + + float targetPosition = hoc.ToLocalSpace(ToScreenSpace(position)).Y; + + if (drawableRuleset.ScrollingInfo.Direction.Value == ScrollingDirection.Down) + { + // We're dealing with screen coordinates in which the position decreases towards the centre of the screen resulting in an increase in start time. + // The scrolling algorithm instead assumes a top anchor meaning an increase in time corresponds to an increase in position, + // so when scrolling downwards the coordinates need to be flipped. + targetPosition = hoc.DrawHeight - targetPosition; + } + + double targetTime = drawableRuleset.ScrollingInfo.Algorithm.TimeAt(targetPosition, + EditorClock.CurrentTime, + drawableRuleset.ScrollingInfo.TimeRange.Value, + hoc.DrawHeight); + + return base.GetSnappedPosition(position, targetTime); + } protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) { diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index 9069a636a8..55245198c8 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -4,11 +4,8 @@ using System; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Timing; -using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; @@ -22,85 +19,16 @@ namespace osu.Game.Rulesets.Mania.Edit [Resolved] private IManiaHitObjectComposer composer { get; set; } - private IClock editorClock; - - [BackgroundDependencyLoader] - private void load(IAdjustableClock clock) - { - editorClock = clock; - } - public override bool HandleMovement(MoveSelectionEvent moveEvent) { var maniaBlueprint = (ManiaSelectionBlueprint)moveEvent.Blueprint; int lastColumn = maniaBlueprint.DrawableObject.HitObject.Column; - adjustOrigins(maniaBlueprint); - performDragMovement(moveEvent); performColumnMovement(lastColumn, moveEvent); return true; } - /// - /// Ensures that the position of hitobjects remains centred to the mouse position. - /// E.g. The hitobject position will change if the editor scrolls while a hitobject is dragged. - /// - /// The that received the drag event. - private void adjustOrigins(ManiaSelectionBlueprint reference) - { - var referenceParent = (HitObjectContainer)reference.DrawableObject.Parent; - - float offsetFromReferenceOrigin = reference.DragPosition.Y - reference.DrawableObject.OriginPosition.Y; - float targetPosition = referenceParent.ToLocalSpace(reference.ScreenSpaceDragPosition).Y - offsetFromReferenceOrigin; - - // Flip the vertical coordinate space when scrolling downwards - if (scrollingInfo.Direction.Value == ScrollingDirection.Down) - targetPosition -= referenceParent.DrawHeight; - - float movementDelta = targetPosition - reference.DrawableObject.Position.Y; - - foreach (var b in SelectedBlueprints.OfType()) - b.DrawableObject.Y += movementDelta; - } - - private void performDragMovement(MoveSelectionEvent moveEvent) - { - float delta = moveEvent.InstantDelta.Y; - - // When scrolling downwards the anchor position is at the bottom of the screen, however the movement event assumes the anchor is at the top of the screen. - // This causes the delta to assume a positive hitobject position, and which can be corrected for by subtracting the parent height. - if (scrollingInfo.Direction.Value == ScrollingDirection.Down) - delta -= moveEvent.Blueprint.Parent.DrawHeight; // todo: probably wrong - - foreach (var selectionBlueprint in SelectedBlueprints) - { - var b = (OverlaySelectionBlueprint)selectionBlueprint; - - var hitObject = b.DrawableObject; - var objectParent = (HitObjectContainer)hitObject.Parent; - - // StartTime could be used to adjust the position if only one movement event was received per frame. - // However this is not the case and ScrollingHitObjectContainer performs movement in UpdateAfterChildren() so the position must also be updated to be valid for further movement events - hitObject.Y += delta; - - float targetPosition = hitObject.Position.Y; - - // The scrolling algorithm always assumes an anchor at the top of the screen, so the position must be flipped when scrolling downwards to reflect a top anchor - if (scrollingInfo.Direction.Value == ScrollingDirection.Down) - targetPosition = -targetPosition; - - objectParent.Remove(hitObject); - - hitObject.HitObject.StartTime = scrollingInfo.Algorithm.TimeAt(targetPosition, - editorClock.CurrentTime, - scrollingInfo.TimeRange.Value, - objectParent.DrawHeight); - - objectParent.Add(hitObject); - } - } - private void performColumnMovement(int lastColumn, MoveSelectionEvent moveEvent) { var currentColumn = composer.ColumnAt(moveEvent.ScreenSpacePosition); diff --git a/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs deleted file mode 100644 index 433db79ae0..0000000000 --- a/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects.Drawables; - -namespace osu.Game.Rulesets.Mania.Edit.Masks -{ - public abstract class ManiaSelectionBlueprint : OverlaySelectionBlueprint - { - protected ManiaSelectionBlueprint(DrawableHitObject drawableObject) - : base(drawableObject) - { - RelativeSizeAxes = Axes.None; - } - } -} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index 88888001b4..a44d8b09aa 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -13,11 +13,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { public abstract class DrawableManiaHitObject : DrawableHitObject { - /// - /// Whether this should always remain alive. - /// - internal bool AlwaysAlive; - /// /// The which causes this to be hit. /// @@ -54,7 +49,62 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Direction.BindValueChanged(OnDirectionChanged, true); } - protected override bool ShouldBeAlive => AlwaysAlive || base.ShouldBeAlive; + private double computedLifetimeStart; + + public override double LifetimeStart + { + get => base.LifetimeStart; + set + { + computedLifetimeStart = value; + + if (!AlwaysAlive) + base.LifetimeStart = value; + } + } + + private double computedLifetimeEnd; + + public override double LifetimeEnd + { + get => base.LifetimeEnd; + set + { + computedLifetimeEnd = value; + + if (!AlwaysAlive) + base.LifetimeEnd = value; + } + } + + private bool alwaysAlive; + + /// + /// Whether this should always remain alive. + /// + internal bool AlwaysAlive + { + get => alwaysAlive; + set + { + if (alwaysAlive == value) + return; + + alwaysAlive = value; + + if (value) + { + // Set the base lifetimes directly, to avoid mangling the computed lifetimes + base.LifetimeStart = double.MinValue; + base.LifetimeEnd = double.MaxValue; + } + else + { + LifetimeStart = computedLifetimeStart; + LifetimeEnd = computedLifetimeEnd; + } + } + } protected virtual void OnDirectionChanged(ValueChangedEvent e) { diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs index 2dec468654..1af7d06998 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs @@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Mania.UI { foreach (var column in stage.Columns) { - if (column.ReceivePositionalInputAt(screenSpacePosition)) + if (column.ReceivePositionalInputAt(new Vector2(screenSpacePosition.X, column.ScreenSpaceDrawQuad.Centre.Y))) { found = column; break; @@ -87,6 +87,31 @@ namespace osu.Game.Rulesets.Mania.UI return found; } + /// + /// Retrieves a by index. + /// + /// The index of the column. + /// The corresponding to the given index. + /// If is less than 0 or greater than . + public Column GetColumn(int index) + { + if (index < 0 || index > TotalColumns - 1) + throw new ArgumentOutOfRangeException(nameof(index)); + + foreach (var stage in stages) + { + if (index >= stage.Columns.Count) + { + index -= stage.Columns.Count; + continue; + } + + return stage.Columns[index]; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + /// /// Retrieves the total amount of columns across all stages in this playfield. /// diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index 0a1c35c7c6..0ffff0ec26 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.UI /// public PassThroughInputManager KeyBindingInputManager; - public override double GameplayStartTime => Objects.First().StartTime - 2000; + public override double GameplayStartTime => Objects.FirstOrDefault()?.StartTime - 2000 ?? 0; private readonly Lazy playfield; diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index ad16e22e5e..8910684463 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -401,12 +401,13 @@ namespace osu.Game.Screens.Edit.Compose.Components HitObject draggedObject = movementBlueprint.HitObject; - // The final movement position, relative to screenSpaceMovementStartPosition + // The final movement position, relative to movementBlueprintOriginalPosition. Vector2 movePosition = movementBlueprintOriginalPosition.Value + e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; + // Retrieve a snapped position. (Vector2 snappedPosition, double snappedTime) = snapProvider.GetSnappedPosition(ToLocalSpace(movePosition), draggedObject.StartTime); - // Move the hitobjects + // Move the hitobjects. if (!selectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprint, ToScreenSpace(snappedPosition)))) return true; diff --git a/osu.Game/Tests/Visual/EditorClockTestScene.cs b/osu.Game/Tests/Visual/EditorClockTestScene.cs index 58a443ed3d..830e6ed363 100644 --- a/osu.Game/Tests/Visual/EditorClockTestScene.cs +++ b/osu.Game/Tests/Visual/EditorClockTestScene.cs @@ -15,7 +15,7 @@ namespace osu.Game.Tests.Visual /// Provides a clock, beat-divisor, and scrolling capability for test cases of editor components that /// are preferrably tested within the presence of a clock and seek controls. /// - public abstract class EditorClockTestScene : OsuTestScene + public abstract class EditorClockTestScene : OsuManualInputManagerTestScene { protected readonly BindableBeatDivisor BeatDivisor = new BindableBeatDivisor(); protected new readonly EditorClock Clock;