diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchDistanceSnapGrid.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchDistanceSnapGrid.cs new file mode 100644 index 0000000000..2be0b7e9b2 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchDistanceSnapGrid.cs @@ -0,0 +1,91 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Edit; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public class TestSceneCatchDistanceSnapGrid : OsuManualInputManagerTestScene + { + private readonly ManualClock manualClock = new ManualClock(); + + [Cached(typeof(Playfield))] + private readonly CatchPlayfield playfield; + + private ScrollingHitObjectContainer hitObjectContainer => playfield.HitObjectContainer; + + private readonly CatchDistanceSnapGrid distanceGrid; + + private readonly FruitOutline fruitOutline; + + private readonly Fruit fruit = new Fruit(); + + public TestSceneCatchDistanceSnapGrid() + { + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Width = 500, + + Children = new Drawable[] + { + new ScrollingTestContainer(ScrollingDirection.Down) + { + RelativeSizeAxes = Axes.Both, + Child = playfield = new CatchPlayfield(new BeatmapDifficulty()) + { + RelativeSizeAxes = Axes.Both, + Clock = new FramedClock(manualClock) + } + }, + distanceGrid = new CatchDistanceSnapGrid(new double[] { 0, -1, 1 }), + fruitOutline = new FruitOutline() + }, + }; + } + + protected override void Update() + { + base.Update(); + + distanceGrid.StartTime = 100; + distanceGrid.StartX = 250; + + Vector2 screenSpacePosition = InputManager.CurrentState.Mouse.Position; + + var result = distanceGrid.GetSnappedPosition(screenSpacePosition); + + if (result != null) + { + fruit.OriginalX = hitObjectContainer.ToLocalSpace(result.ScreenSpacePosition).X; + + if (result.Time != null) + fruit.StartTime = result.Time.Value; + } + + fruitOutline.Position = CatchHitObjectUtils.GetStartPosition(hitObjectContainer, fruit); + fruitOutline.UpdateFrom(fruit); + } + + protected override bool OnScroll(ScrollEvent e) + { + manualClock.CurrentTime -= e.ScrollDelta.Y * 50; + return true; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapGrid.cs b/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapGrid.cs new file mode 100644 index 0000000000..137ac1fc59 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapGrid.cs @@ -0,0 +1,141 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Lines; +using osu.Framework.Graphics.Primitives; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit +{ + /// + /// The guide lines used in the osu!catch editor to compose patterns that can be caught with constant speed. + /// Currently, only forward placement (an object is snapped based on the previous object, not the opposite) is supported. + /// + public class CatchDistanceSnapGrid : CompositeDrawable + { + public double StartTime { get; set; } + + public float StartX { get; set; } + + private const double max_vertical_line_length_in_time = CatchPlayfield.WIDTH / Catcher.BASE_SPEED * 2; + + private readonly double[] velocities; + + private readonly List verticalPaths = new List(); + + private readonly List verticalLineVertices = new List(); + + [Resolved] + private Playfield playfield { get; set; } + + private ScrollingHitObjectContainer hitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; + + public CatchDistanceSnapGrid(double[] velocities) + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.BottomLeft; + + this.velocities = velocities; + + for (int i = 0; i < velocities.Length; i++) + { + verticalPaths.Add(new SmoothPath + { + PathRadius = 2, + Alpha = 0.5f, + }); + + verticalLineVertices.Add(new[] { Vector2.Zero, Vector2.Zero }); + } + + AddRangeInternal(verticalPaths); + } + + protected override void Update() + { + base.Update(); + + double currentTime = hitObjectContainer.Time.Current; + + for (int i = 0; i < velocities.Length; i++) + { + double velocity = velocities[i]; + + // The line ends at the top of the playfield. + double endTime = hitObjectContainer.TimeAtPosition(-hitObjectContainer.DrawHeight, currentTime); + + // Non-vertical lines are cut at the sides of the playfield. + // Vertical lines are cut at some reasonable length. + if (velocity > 0) + endTime = Math.Min(endTime, StartTime + (CatchPlayfield.WIDTH - StartX) / velocity); + else if (velocity < 0) + endTime = Math.Min(endTime, StartTime + StartX / -velocity); + else + endTime = Math.Min(endTime, StartTime + max_vertical_line_length_in_time); + + Vector2[] lineVertices = verticalLineVertices[i]; + lineVertices[0] = calculatePosition(velocity, StartTime); + lineVertices[1] = calculatePosition(velocity, endTime); + + var verticalPath = verticalPaths[i]; + verticalPath.Vertices = verticalLineVertices[i]; + verticalPath.OriginPosition = verticalPath.PositionInBoundingBox(Vector2.Zero); + } + + Vector2 calculatePosition(double velocity, double time) + { + // Don't draw inverted lines. + time = Math.Max(time, StartTime); + + float x = StartX + (float)((time - StartTime) * velocity); + float y = hitObjectContainer.PositionAtTime(time, currentTime); + return new Vector2(x, y); + } + } + + [CanBeNull] + public SnapResult GetSnappedPosition(Vector2 screenSpacePosition) + { + double time = hitObjectContainer.TimeAtScreenSpacePosition(screenSpacePosition); + + // If the cursor is below the distance snap grid, snap to the origin. + // Not returning `null` to retain the continuous snapping behavior when the cursor is slightly below the origin. + // This behavior is not currently visible in the editor because editor chooses the snap start time based on the mouse position. + if (time <= StartTime) + { + float y = hitObjectContainer.PositionAtTime(StartTime); + Vector2 originPosition = hitObjectContainer.ToScreenSpace(new Vector2(StartX, y)); + return new SnapResult(originPosition, StartTime); + } + + return enumerateSnappingCandidates(time) + .OrderBy(pos => Vector2.DistanceSquared(screenSpacePosition, pos.ScreenSpacePosition)) + .FirstOrDefault(); + } + + private IEnumerable enumerateSnappingCandidates(double time) + { + float y = hitObjectContainer.PositionAtTime(time); + + foreach (double velocity in velocities) + { + float x = (float)(StartX + (time - StartTime) * velocity); + Vector2 screenSpacePosition = hitObjectContainer.ToScreenSpace(new Vector2(x, y + hitObjectContainer.DrawHeight)); + yield return new SnapResult(screenSpacePosition, time); + } + } + + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index 050c2f625d..67055fb5e0 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -2,14 +2,23 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -17,6 +26,14 @@ namespace osu.Game.Rulesets.Catch.Edit { public class CatchHitObjectComposer : HitObjectComposer { + private const float distance_snap_radius = 50; + + private CatchDistanceSnapGrid distanceSnapGrid; + + private readonly Bindable distanceSnapToggle = new Bindable(); + + private InputManager inputManager; + public CatchHitObjectComposer(CatchRuleset ruleset) : base(ruleset) { @@ -30,6 +47,27 @@ private void load() RelativeSizeAxes = Axes.Both, PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } }); + + LayerBelowRuleset.Add(distanceSnapGrid = new CatchDistanceSnapGrid(new[] + { + 0.0, + Catcher.BASE_SPEED, -Catcher.BASE_SPEED, + Catcher.BASE_SPEED / 2, -Catcher.BASE_SPEED / 2, + })); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } + + protected override void Update() + { + base.Update(); + + updateDistanceSnapGrid(); } protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) => @@ -42,14 +80,95 @@ protected override DrawableRuleset CreateDrawableRuleset(Ruleset new BananaShowerCompositionTool() }; + protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] + { + new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) + }); + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) { var result = base.SnapScreenSpacePositionToValidTime(screenSpacePosition); - // TODO: implement position snap result.ScreenSpacePosition.X = screenSpacePosition.X; + + if (distanceSnapGrid.IsPresent && distanceSnapGrid.GetSnappedPosition(result.ScreenSpacePosition) is SnapResult snapResult && + Vector2.Distance(snapResult.ScreenSpacePosition, result.ScreenSpacePosition) < distance_snap_radius) + { + result = snapResult; + } + return result; } protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this); + + [CanBeNull] + private PalpableCatchHitObject getLastSnappableHitObject(double time) + { + var hitObject = EditorBeatmap.HitObjects.OfType().LastOrDefault(h => h.GetEndTime() < time && !(h is BananaShower)); + + switch (hitObject) + { + case Fruit fruit: + return fruit; + + case JuiceStream juiceStream: + return juiceStream.NestedHitObjects.OfType().LastOrDefault(h => !(h is TinyDroplet)); + + default: + return null; + } + } + + [CanBeNull] + private PalpableCatchHitObject getDistanceSnapGridSourceHitObject() + { + switch (BlueprintContainer.CurrentTool) + { + case SelectTool _: + if (EditorBeatmap.SelectedHitObjects.Count == 0) + return null; + + double minTime = EditorBeatmap.SelectedHitObjects.Min(hitObject => hitObject.StartTime); + return getLastSnappableHitObject(minTime); + + case FruitCompositionTool _: + case JuiceStreamCompositionTool _: + if (!CursorInPlacementArea) + return null; + + if (EditorBeatmap.PlacementObject.Value is JuiceStream) + { + // Juice stream path is not subject to snapping. + return null; + } + + double timeAtCursor = ((CatchPlayfield)Playfield).TimeAtScreenSpacePosition(inputManager.CurrentState.Mouse.Position); + return getLastSnappableHitObject(timeAtCursor); + + default: + return null; + } + } + + private void updateDistanceSnapGrid() + { + if (distanceSnapToggle.Value != TernaryState.True) + { + distanceSnapGrid.Hide(); + return; + } + + var sourceHitObject = getDistanceSnapGridSourceHitObject(); + + if (sourceHitObject == null) + { + distanceSnapGrid.Hide(); + return; + } + + distanceSnapGrid.Show(); + distanceSnapGrid.StartTime = sourceHitObject.GetEndTime(); + distanceSnapGrid.StartX = sourceHitObject.EffectiveX; + } } }