diff --git a/osu.Android.props b/osu.Android.props
index eaad4daf35..f0f16d3763 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,6 +52,6 @@
-
+
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaBeatSnapGrid.cs
new file mode 100644
index 0000000000..ce9546415f
--- /dev/null
+++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaBeatSnapGrid.cs
@@ -0,0 +1,70 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Input.Events;
+using osu.Framework.Timing;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Configuration;
+using osu.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Rulesets.Mania.Edit;
+using osu.Game.Rulesets.Mania.UI;
+using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Screens.Edit;
+using osu.Game.Tests.Visual;
+
+namespace osu.Game.Rulesets.Mania.Tests
+{
+ [Cached(typeof(IManiaHitObjectComposer))]
+ public class TestSceneManiaBeatSnapGrid : EditorClockTestScene, IManiaHitObjectComposer
+ {
+ [Cached(typeof(IScrollingInfo))]
+ private ScrollingTestContainer.TestScrollingInfo scrollingInfo = new ScrollingTestContainer.TestScrollingInfo();
+
+ [Cached(typeof(EditorBeatmap))]
+ private EditorBeatmap editorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition()));
+
+ private readonly ManiaBeatSnapGrid beatSnapGrid;
+
+ public TestSceneManiaBeatSnapGrid()
+ {
+ editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 200 });
+ editorBeatmap.ControlPointInfo.Add(10000, new TimingControlPoint { BeatLength = 200 });
+
+ BeatDivisor.Value = 3;
+
+ // Some sane defaults
+ scrollingInfo.Algorithm.Algorithm = ScrollVisualisationMethod.Constant;
+ scrollingInfo.Direction.Value = ScrollingDirection.Up;
+ scrollingInfo.TimeRange.Value = 1000;
+
+ Children = new Drawable[]
+ {
+ Playfield = new ManiaPlayfield(new List
+ {
+ new StageDefinition { Columns = 4 },
+ new StageDefinition { Columns = 3 }
+ })
+ {
+ Clock = new FramedClock(new StopwatchClock())
+ },
+ beatSnapGrid = new ManiaBeatSnapGrid()
+ };
+ }
+
+ protected override bool OnMouseMove(MouseMoveEvent e)
+ {
+ // We're providing a constant scroll algorithm.
+ float relativePosition = Playfield.Stages[0].HitObjectContainer.ToLocalSpace(e.ScreenSpaceMousePosition).Y / Playfield.Stages[0].HitObjectContainer.DrawHeight;
+ double timeValue = scrollingInfo.TimeRange.Value * relativePosition;
+
+ beatSnapGrid.SelectionTimeRange = (timeValue, timeValue);
+
+ return true;
+ }
+
+ public ManiaPlayfield Playfield { get; }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs
new file mode 100644
index 0000000000..b5b6c08fca
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.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 System.Collections.Generic;
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Caching;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Shapes;
+using osu.Game.Beatmaps;
+using osu.Game.Graphics;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Drawables;
+using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Screens.Edit;
+using osuTK.Graphics;
+
+namespace osu.Game.Rulesets.Mania.Edit
+{
+ ///
+ /// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor.
+ ///
+ public class ManiaBeatSnapGrid : Component
+ {
+ private const double visible_range = 750;
+
+ ///
+ /// The range of time values of the current selection.
+ ///
+ public (double start, double end)? SelectionTimeRange
+ {
+ set
+ {
+ if (value == selectionTimeRange)
+ return;
+
+ selectionTimeRange = value;
+ lineCache.Invalidate();
+ }
+ }
+
+ [Resolved]
+ private EditorBeatmap beatmap { get; set; }
+
+ [Resolved]
+ private IScrollingInfo scrollingInfo { get; set; }
+
+ [Resolved]
+ private Bindable working { get; set; }
+
+ [Resolved]
+ private OsuColour colours { get; set; }
+
+ [Resolved]
+ private BindableBeatDivisor beatDivisor { get; set; }
+
+ private readonly List grids = new List();
+
+ private readonly Cached lineCache = new Cached();
+
+ private (double start, double end)? selectionTimeRange;
+
+ [BackgroundDependencyLoader]
+ private void load(IManiaHitObjectComposer composer)
+ {
+ foreach (var stage in composer.Playfield.Stages)
+ {
+ foreach (var column in stage.Columns)
+ {
+ var lineContainer = new ScrollingHitObjectContainer();
+
+ grids.Add(lineContainer);
+ column.UnderlayElements.Add(lineContainer);
+ }
+ }
+
+ beatDivisor.BindValueChanged(_ => createLines(), true);
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ if (!lineCache.IsValid)
+ {
+ lineCache.Validate();
+ createLines();
+ }
+ }
+
+ private readonly Stack availableLines = new Stack();
+
+ private void createLines()
+ {
+ foreach (var grid in grids)
+ {
+ foreach (var line in grid.Objects.OfType())
+ availableLines.Push(line);
+
+ grid.Clear(false);
+ }
+
+ if (selectionTimeRange == null)
+ return;
+
+ var range = selectionTimeRange.Value;
+
+ var timingPoint = beatmap.ControlPointInfo.TimingPointAt(range.start - visible_range);
+
+ double time = timingPoint.Time;
+ int beat = 0;
+
+ // progress time until in the visible range.
+ while (time < range.start - visible_range)
+ {
+ time += timingPoint.BeatLength / beatDivisor.Value;
+ beat++;
+ }
+
+ while (time < range.end + visible_range)
+ {
+ var nextTimingPoint = beatmap.ControlPointInfo.TimingPointAt(time);
+
+ // switch to the next timing point if we have reached it.
+ if (nextTimingPoint.Time > timingPoint.Time)
+ {
+ beat = 0;
+ time = nextTimingPoint.Time;
+ timingPoint = nextTimingPoint;
+ }
+
+ Color4 colour = BindableBeatDivisor.GetColourFor(
+ BindableBeatDivisor.GetDivisorForBeatIndex(Math.Max(1, beat), beatDivisor.Value), colours);
+
+ foreach (var grid in grids)
+ {
+ if (!availableLines.TryPop(out var line))
+ line = new DrawableGridLine();
+
+ line.HitObject.StartTime = time;
+ line.Colour = colour;
+
+ grid.Add(line);
+ }
+
+ beat++;
+ time += timingPoint.BeatLength / beatDivisor.Value;
+ }
+
+ foreach (var grid in grids)
+ {
+ // required to update ScrollingHitObjectContainer's cache.
+ grid.UpdateSubTree();
+
+ foreach (var line in grid.Objects.OfType())
+ {
+ time = line.HitObject.StartTime;
+
+ if (time >= range.start && time <= range.end)
+ line.Alpha = 1;
+ else
+ {
+ double timeSeparation = time < range.start ? range.start - time : time - range.end;
+ line.Alpha = (float)Math.Max(0, 1 - timeSeparation / visible_range);
+ }
+ }
+ }
+ }
+
+ private class DrawableGridLine : DrawableHitObject
+ {
+ [Resolved]
+ private IScrollingInfo scrollingInfo { get; set; }
+
+ private readonly IBindable direction = new Bindable();
+
+ public DrawableGridLine()
+ : base(new HitObject())
+ {
+ RelativeSizeAxes = Axes.X;
+ Height = 2;
+
+ AddInternal(new Box { RelativeSizeAxes = Axes.Both });
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ direction.BindTo(scrollingInfo.Direction);
+ direction.BindValueChanged(onDirectionChanged, true);
+ }
+
+ private void onDirectionChanged(ValueChangedEvent direction)
+ {
+ Origin = Anchor = direction.NewValue == ScrollingDirection.Up
+ ? Anchor.TopLeft
+ : Anchor.BottomLeft;
+ }
+
+ protected override void UpdateInitialTransforms()
+ {
+ // don't perform any fading – we are handling that ourselves.
+ }
+
+ protected override void UpdateStateTransforms(ArmedState state)
+ {
+ LifetimeEnd = HitObject.StartTime + visible_range;
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs
index 8367b4f5e9..683e921cbf 100644
--- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs
+++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs
@@ -6,9 +6,12 @@ using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mania.Objects;
using System.Collections.Generic;
+using System.Linq;
using osu.Framework.Allocation;
+using osu.Framework.Input;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit.Compose.Components;
@@ -20,12 +23,27 @@ namespace osu.Game.Rulesets.Mania.Edit
public class ManiaHitObjectComposer : HitObjectComposer, IManiaHitObjectComposer
{
private DrawableManiaEditRuleset drawableRuleset;
+ private ManiaBeatSnapGrid beatSnapGrid;
+ private InputManager inputManager;
public ManiaHitObjectComposer(Ruleset ruleset)
: base(ruleset)
{
}
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ AddInternal(beatSnapGrid = new ManiaBeatSnapGrid());
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ inputManager = GetContainingInputManager();
+ }
+
private DependencyContainer dependencies;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
@@ -70,5 +88,28 @@ namespace osu.Game.Rulesets.Mania.Edit
new NoteCompositionTool(),
new HoldNoteCompositionTool()
};
+
+ protected override void UpdateAfterChildren()
+ {
+ base.UpdateAfterChildren();
+
+ if (BlueprintContainer.CurrentTool is SelectTool)
+ {
+ if (EditorBeatmap.SelectedHitObjects.Any())
+ {
+ beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime()));
+ }
+ else
+ beatSnapGrid.SelectionTimeRange = null;
+ }
+ else
+ {
+ var result = SnapScreenSpacePositionToValidTime(inputManager.CurrentState.Mouse.Position);
+ if (result.Time is double time)
+ beatSnapGrid.SelectionTimeRange = (time, time);
+ else
+ beatSnapGrid.SelectionTimeRange = null;
+ }
+ }
}
}
diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs
index 2d88670d77..be31954099 100644
--- a/osu.Game.Rulesets.Mania/UI/Column.cs
+++ b/osu.Game.Rulesets.Mania/UI/Column.cs
@@ -38,6 +38,8 @@ namespace osu.Game.Rulesets.Mania.UI
internal readonly Container TopLevelContainer;
+ public Container UnderlayElements => hitObjectArea.UnderlayElements;
+
public Column(int index)
{
Index = index;
diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs
index cb79bf7f43..b365ae45a9 100644
--- a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs
+++ b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs
@@ -12,6 +12,9 @@ namespace osu.Game.Rulesets.Mania.UI.Components
public class ColumnHitObjectArea : HitObjectArea
{
public readonly Container Explosions;
+
+ public readonly Container UnderlayElements;
+
private readonly Drawable hitTarget;
public ColumnHitObjectArea(int columnIndex, HitObjectContainer hitObjectContainer)
@@ -19,6 +22,11 @@ namespace osu.Game.Rulesets.Mania.UI.Components
{
AddRangeInternal(new[]
{
+ UnderlayElements = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Depth = 2,
+ },
hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget, columnIndex), _ => new DefaultHitTarget())
{
RelativeSizeAxes = Axes.X,
diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
index 1af7d06998..271e432e8d 100644
--- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
+++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
@@ -18,6 +18,8 @@ namespace osu.Game.Rulesets.Mania.UI
[Cached]
public class ManiaPlayfield : ScrollingPlayfield
{
+ public IReadOnlyList Stages => stages;
+
private readonly List stages = new List();
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => stages.Any(s => s.ReceivePositionalInputAt(screenSpacePos));
diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
index d594909cda..44afb7a227 100644
--- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
+++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
@@ -257,7 +257,7 @@ namespace osu.Game.Rulesets.Objects.Drawables
}
}
- if (state.Value != ArmedState.Idle && LifetimeEnd == double.MaxValue || HitObject.HitWindows == null)
+ if (LifetimeEnd == double.MaxValue && (state.Value != ArmedState.Idle || HitObject.HitWindows == null))
Expire();
// apply any custom state overrides
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 9112dfe46e..010ef8578a 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -24,7 +24,7 @@
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index 3f0630af5f..88b0c7dd8a 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -70,7 +70,7 @@
-
+
@@ -80,7 +80,7 @@
-
+