diff --git a/osu.Game.Tests/Visual/Editor/TestSceneBeatSnapGrid.cs b/osu.Game.Tests/Visual/Editor/TestSceneBeatSnapGrid.cs new file mode 100644 index 0000000000..073cec7315 --- /dev/null +++ b/osu.Game.Tests/Visual/Editor/TestSceneBeatSnapGrid.cs @@ -0,0 +1,213 @@ +// 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.Shapes; +using osu.Framework.MathUtils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Editor +{ + public class TestSceneBeatSnapGrid : EditorClockTestScene + { + private const double beat_length = 100; + private static readonly Vector2 grid_position = new Vector2(512, 384); + + [Cached(typeof(IEditorBeatmap))] + private readonly EditorBeatmap editorBeatmap; + + private TestBeatSnapGrid grid; + + public TestSceneBeatSnapGrid() + { + editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + editorBeatmap.ControlPointInfo.TimingPoints.Add(new TimingControlPoint { BeatLength = beat_length }); + + createGrid(); + } + + [SetUp] + public void Setup() => Schedule(() => + { + Clear(); + + editorBeatmap.ControlPointInfo.TimingPoints.Clear(); + editorBeatmap.ControlPointInfo.TimingPoints.Add(new TimingControlPoint { BeatLength = beat_length }); + + BeatDivisor.Value = 1; + }); + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(6)] + [TestCase(8)] + [TestCase(12)] + [TestCase(16)] + public void TestInitialBeatDivisor(int divisor) + { + AddStep($"set beat divisor = {divisor}", () => BeatDivisor.Value = divisor); + createGrid(); + + float expectedDistance = (float)beat_length / divisor; + AddAssert($"spacing is {expectedDistance}", () => Precision.AlmostEquals(grid.DistanceSpacing, expectedDistance)); + } + + [Test] + public void TestChangeBeatDivisor() + { + createGrid(); + AddStep("set beat divisor = 2", () => BeatDivisor.Value = 2); + + const float expected_distance = (float)beat_length / 2; + AddAssert($"spacing is {expected_distance}", () => Precision.AlmostEquals(grid.DistanceSpacing, expected_distance)); + } + + [TestCase(100)] + [TestCase(200)] + public void TestBeatLength(double beatLength) + { + AddStep($"set beat length = {beatLength}", () => + { + editorBeatmap.ControlPointInfo.TimingPoints.Clear(); + editorBeatmap.ControlPointInfo.TimingPoints.Add(new TimingControlPoint { BeatLength = beatLength }); + }); + + createGrid(); + AddAssert($"spacing is {beatLength}", () => Precision.AlmostEquals(grid.DistanceSpacing, beatLength)); + } + + [TestCase(1)] + [TestCase(2)] + public void TestGridVelocity(float velocity) + { + createGrid(g => g.Velocity = velocity); + + float expectedDistance = (float)beat_length * velocity; + AddAssert($"spacing is {expectedDistance}", () => Precision.AlmostEquals(grid.DistanceSpacing, expectedDistance)); + } + + [Test] + public void TestGetSnappedTime() + { + createGrid(); + + Vector2 snapPosition = Vector2.Zero; + AddStep("get first tick position", () => snapPosition = grid_position + new Vector2((float)beat_length, 0)); + AddAssert("snap time is 1 beat away", () => Precision.AlmostEquals(beat_length, grid.GetSnapTime(snapPosition), 0.01)); + + createGrid(g => g.Velocity = 2, "with velocity = 2"); + AddAssert("snap time is now 0.5 beats away", () => Precision.AlmostEquals(beat_length / 2, grid.GetSnapTime(snapPosition), 0.01)); + } + + private void createGrid(Action func = null, string description = null) + { + AddStep($"create grid {description ?? string.Empty}", () => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.SlateGray + }, + grid = new TestBeatSnapGrid(new HitObject(), grid_position) + }; + + func?.Invoke(grid); + }); + } + + private class TestBeatSnapGrid : BeatSnapGrid + { + public new float Velocity = 1; + + public new float DistanceSpacing => base.DistanceSpacing; + + public TestBeatSnapGrid(HitObject hitObject, Vector2 centrePosition) + : base(hitObject, centrePosition) + { + } + + protected override void CreateContent(Vector2 centrePosition) + { + AddInternal(new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(5), + Position = centrePosition + }); + + int beatIndex = 0; + + for (float s = centrePosition.X + DistanceSpacing; s <= DrawWidth; s += DistanceSpacing, beatIndex++) + { + AddInternal(new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(5, 10), + Position = new Vector2(s, centrePosition.Y), + Colour = GetColourForBeatIndex(beatIndex) + }); + } + + beatIndex = 0; + + for (float s = centrePosition.X - DistanceSpacing; s >= 0; s -= DistanceSpacing, beatIndex++) + { + AddInternal(new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(5, 10), + Position = new Vector2(s, centrePosition.Y), + Colour = GetColourForBeatIndex(beatIndex) + }); + } + + beatIndex = 0; + + for (float s = centrePosition.Y + DistanceSpacing; s <= DrawHeight; s += DistanceSpacing, beatIndex++) + { + AddInternal(new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(10, 5), + Position = new Vector2(centrePosition.X, s), + Colour = GetColourForBeatIndex(beatIndex) + }); + } + + beatIndex = 0; + + for (float s = centrePosition.Y - DistanceSpacing; s >= 0; s -= DistanceSpacing, beatIndex++) + { + AddInternal(new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(10, 5), + Position = new Vector2(centrePosition.X, s), + Colour = GetColourForBeatIndex(beatIndex) + }); + } + } + + protected override float GetVelocity(double time, ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + => Velocity; + + public override Vector2 GetSnapPosition(Vector2 screenSpacePosition) + => Vector2.Zero; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs new file mode 100644 index 0000000000..9040843144 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs @@ -0,0 +1,153 @@ +// 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.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public abstract class BeatSnapGrid : CompositeDrawable + { + /// + /// The velocity of the beatmap at the point of placement in pixels per millisecond. + /// + protected double Velocity { get; private set; } + + /// + /// The spacing between each tick of the beat snapping grid. + /// + protected float DistanceSpacing { get; private set; } + + /// + /// The position which the grid is centred on. + /// The first beat snapping tick is located at + in the desired direction. + /// + protected readonly Vector2 CentrePosition; + + [Resolved] + private IEditorBeatmap beatmap { get; set; } + + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + private readonly Cached gridCache = new Cached(); + private readonly HitObject hitObject; + + private double startTime; + private double beatLength; + + protected BeatSnapGrid(HitObject hitObject, Vector2 centrePosition) + { + this.hitObject = hitObject; + this.CentrePosition = centrePosition; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + startTime = (hitObject as IHasEndTime)?.EndTime ?? hitObject.StartTime; + beatLength = beatmap.ControlPointInfo.TimingPointAt(startTime).BeatLength; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatDivisor.BindValueChanged(_ => updateSpacing(), true); + } + + private void updateSpacing() + { + Velocity = GetVelocity(startTime, beatmap.ControlPointInfo, beatmap.BeatmapInfo.BaseDifficulty); + DistanceSpacing = (float)(beatLength / beatDivisor.Value * Velocity); + gridCache.Invalidate(); + } + + public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) + { + if ((invalidation & Invalidation.RequiredParentSizeToFit) > 0) + gridCache.Invalidate(); + + return base.Invalidate(invalidation, source, shallPropagate); + } + + protected override void Update() + { + base.Update(); + + if (!gridCache.IsValid) + { + ClearInternal(); + CreateContent(CentrePosition); + gridCache.Validate(); + } + } + + /// + /// Creates the content which visualises the grid ticks. + /// + protected abstract void CreateContent(Vector2 centrePosition); + + /// + /// Retrieves the velocity of gameplay at a point in time in pixels per millisecond. + /// + /// The time to retrieve the velocity at. + /// The beatmap's at the point in time. + /// The beatmap's at the point in time. + /// The velocity. + protected abstract float GetVelocity(double time, ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty); + + /// + /// Snaps a position to this grid. + /// + /// The original position in coordinate space local to this . + /// The snapped position in coordinate space local to this . + public abstract Vector2 GetSnapPosition(Vector2 position); + + /// + /// Retrieves the time at a snapped position. + /// + /// The snapped position in coordinate space local to this . + /// The time at the snapped position. + public double GetSnapTime(Vector2 position) => startTime + (position - CentrePosition).Length / Velocity; + + /// + /// Retrieves the applicable colour for a beat index. + /// + /// The 0-based beat index. + /// The applicable colour. + protected ColourInfo GetColourForBeatIndex(int index) + { + int beat = (index + 1) % beatDivisor.Value; + ColourInfo colour = colours.Gray5; + + for (int i = 0; i < BindableBeatDivisor.VALID_DIVISORS.Length; i++) + { + int divisor = BindableBeatDivisor.VALID_DIVISORS[i]; + + if ((beat * divisor) % beatDivisor.Value == 0) + { + colour = BindableBeatDivisor.GetColourFor(divisor, colours); + break; + } + } + + int repeatIndex = index / beatDivisor.Value; + return colour.MultiplyAlpha(0.5f / (repeatIndex + 1)); + } + } +}