diff --git a/osu.Android.props b/osu.Android.props index 85766665a9..c5714caf4c 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -62,6 +62,6 @@ - + diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 762a9c418d..2e5fa59d20 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -16,6 +16,11 @@ namespace osu.Android protected override void OnCreate(Bundle savedInstanceState) { + // The default current directory on android is '/'. + // On some devices '/' maps to the app data directory. On others it maps to the root of the internal storage. + // In order to have a consistent current directory on all devices the full path of the app data directory is set as the current directory. + System.Environment.CurrentDirectory = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal); + base.OnCreate(savedInstanceState); Window.AddFlags(WindowManagerFlags.Fullscreen); diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBananaShower.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBananaShower.cs index 42646851d7..ea415e18fa 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBananaShower.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBananaShower.cs @@ -2,35 +2,50 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Catch.Objects.Drawable { public class DrawableBananaShower : DrawableCatchHitObject { + private readonly Func> createDrawableRepresentation; private readonly Container bananaContainer; public DrawableBananaShower(BananaShower s, Func> createDrawableRepresentation = null) : base(s) { + this.createDrawableRepresentation = createDrawableRepresentation; RelativeSizeAxes = Axes.X; Origin = Anchor.BottomLeft; X = 0; AddInternal(bananaContainer = new Container { RelativeSizeAxes = Axes.Both }); - - foreach (var b in s.NestedHitObjects.Cast()) - AddNested(createDrawableRepresentation?.Invoke(b)); } - protected override void AddNested(DrawableHitObject h) + protected override void AddNestedHitObject(DrawableHitObject hitObject) { - ((DrawableCatchHitObject)h).CheckPosition = o => CheckPosition?.Invoke(o) ?? false; - bananaContainer.Add(h); - base.AddNested(h); + base.AddNestedHitObject(hitObject); + bananaContainer.Add(hitObject); + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + bananaContainer.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case Banana banana: + return createDrawableRepresentation?.Invoke(banana)?.With(o => ((DrawableCatchHitObject)o).CheckPosition = p => CheckPosition?.Invoke(p) ?? false); + } + + return base.CreateNestedHitObject(hitObject); } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableJuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableJuiceStream.cs index 9e5e9f6a04..a24821b3ce 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableJuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableJuiceStream.cs @@ -2,38 +2,50 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Catch.Objects.Drawable { public class DrawableJuiceStream : DrawableCatchHitObject { + private readonly Func> createDrawableRepresentation; private readonly Container dropletContainer; public DrawableJuiceStream(JuiceStream s, Func> createDrawableRepresentation = null) : base(s) { + this.createDrawableRepresentation = createDrawableRepresentation; RelativeSizeAxes = Axes.Both; Origin = Anchor.BottomLeft; X = 0; AddInternal(dropletContainer = new Container { RelativeSizeAxes = Axes.Both, }); - - foreach (var o in s.NestedHitObjects.Cast()) - AddNested(createDrawableRepresentation?.Invoke(o)); } - protected override void AddNested(DrawableHitObject h) + protected override void AddNestedHitObject(DrawableHitObject hitObject) { - var catchObject = (DrawableCatchHitObject)h; + base.AddNestedHitObject(hitObject); + dropletContainer.Add(hitObject); + } - catchObject.CheckPosition = o => CheckPosition?.Invoke(o) ?? false; + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + dropletContainer.Clear(); + } - dropletContainer.Add(h); - base.AddNested(h); + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case CatchHitObject catchObject: + return createDrawableRepresentation?.Invoke(catchObject)?.With(o => ((DrawableCatchHitObject)o).CheckPosition = p => CheckPosition?.Invoke(p) ?? false); + } + + return base.CreateNestedHitObject(hitObject); } } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs index d64c5dbc6a..3a9eb1f043 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs @@ -16,45 +16,51 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint { - public new DrawableHoldNote HitObject => (DrawableHoldNote)base.HitObject; + public new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject; private readonly IBindable direction = new Bindable(); - private readonly BodyPiece body; + [Resolved] + private OsuColour colours { get; set; } public HoldNoteSelectionBlueprint(DrawableHoldNote hold) : base(hold) { - InternalChildren = new Drawable[] - { - new HoldNoteNoteSelectionBlueprint(hold.Head), - new HoldNoteNoteSelectionBlueprint(hold.Tail), - body = new BodyPiece - { - AccentColour = Color4.Transparent - }, - }; } [BackgroundDependencyLoader] - private void load(OsuColour colours, IScrollingInfo scrollingInfo) + private void load(IScrollingInfo scrollingInfo) { - body.BorderColour = colours.Yellow; - direction.BindTo(scrollingInfo.Direction); } + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + new HoldNoteNoteSelectionBlueprint(DrawableObject.Head), + new HoldNoteNoteSelectionBlueprint(DrawableObject.Tail), + new BodyPiece + { + AccentColour = Color4.Transparent, + BorderColour = colours.Yellow + }, + }; + } + protected override void Update() { base.Update(); - Size = HitObject.DrawSize + new Vector2(0, HitObject.Tail.DrawHeight); + Size = DrawableObject.DrawSize + new Vector2(0, DrawableObject.Tail.DrawHeight); // This is a side-effect of not matching the hitobject's anchors/origins, which is kinda hard to do // When scrolling upwards our origin is already at the top of the head note (which is the intended location), // but when scrolling downwards our origin is at the _bottom_ of the tail note (where we need to be at the _top_ of the tail note) if (direction.Value == ScrollingDirection.Down) - Y -= HitObject.Tail.DrawHeight; + Y -= DrawableObject.Tail.DrawHeight; } public override Quad SelectionQuad => ScreenSpaceDrawQuad; @@ -71,10 +77,10 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { base.Update(); - Anchor = HitObject.Anchor; - Origin = HitObject.Origin; + Anchor = DrawableObject.Anchor; + Origin = DrawableObject.Origin; - Position = HitObject.DrawPosition; + Position = DrawableObject.DrawPosition; } // Todo: This is temporary, since the note masks don't do anything special yet. In the future they will handle input. diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs index 3142f22fcd..b28d8bb0e6 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs @@ -49,10 +49,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints if (Column == null) return base.OnMouseDown(e); - HitObject.StartTime = TimeAt(e.ScreenSpaceMousePosition); HitObject.Column = Column.Index; - - BeginPlacement(); + BeginPlacement(TimeAt(e.ScreenSpaceMousePosition)); return true; } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs index cc50459a0c..3bd7fb2d49 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints public Vector2 ScreenSpaceDragPosition { get; private set; } public Vector2 DragPosition { get; private set; } - public new DrawableManiaHitObject HitObject => (DrawableManiaHitObject)base.HitObject; + public new DrawableManiaHitObject DrawableObject => (DrawableManiaHitObject)base.DrawableObject; protected IClock EditorClock { get; private set; } @@ -28,8 +28,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints [Resolved] private IManiaHitObjectComposer composer { get; set; } - public ManiaSelectionBlueprint(DrawableHitObject hitObject) - : base(hitObject) + public ManiaSelectionBlueprint(DrawableHitObject drawableObject) + : base(drawableObject) { RelativeSizeAxes = Axes.None; } @@ -44,13 +44,13 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { base.Update(); - Position = Parent.ToLocalSpace(HitObject.ToScreenSpace(Vector2.Zero)); + Position = Parent.ToLocalSpace(DrawableObject.ToScreenSpace(Vector2.Zero)); } protected override bool OnMouseDown(MouseDownEvent e) { ScreenSpaceDragPosition = e.ScreenSpaceMousePosition; - DragPosition = HitObject.ToLocalSpace(e.ScreenSpaceMousePosition); + DragPosition = DrawableObject.ToLocalSpace(e.ScreenSpaceMousePosition); return base.OnMouseDown(e); } @@ -60,20 +60,20 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints var result = base.OnDrag(e); ScreenSpaceDragPosition = e.ScreenSpaceMousePosition; - DragPosition = HitObject.ToLocalSpace(e.ScreenSpaceMousePosition); + DragPosition = DrawableObject.ToLocalSpace(e.ScreenSpaceMousePosition); return result; } public override void Show() { - HitObject.AlwaysAlive = true; + DrawableObject.AlwaysAlive = true; base.Show(); } public override void Hide() { - HitObject.AlwaysAlive = false; + DrawableObject.AlwaysAlive = false; base.Hide(); } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs index d345b14e84..b83c4aa9aa 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { base.Update(); - Size = HitObject.DrawSize; + Size = DrawableObject.DrawSize; } } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index f576c43e52..732231b0d9 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Mania.Edit public override void HandleMovement(MoveSelectionEvent moveEvent) { var maniaBlueprint = (ManiaSelectionBlueprint)moveEvent.Blueprint; - int lastColumn = maniaBlueprint.HitObject.HitObject.Column; + int lastColumn = maniaBlueprint.DrawableObject.HitObject.Column; adjustOrigins(maniaBlueprint); performDragMovement(moveEvent); @@ -48,41 +48,44 @@ namespace osu.Game.Rulesets.Mania.Edit /// The that received the drag event. private void adjustOrigins(ManiaSelectionBlueprint reference) { - var referenceParent = (HitObjectContainer)reference.HitObject.Parent; + var referenceParent = (HitObjectContainer)reference.DrawableObject.Parent; - float offsetFromReferenceOrigin = reference.DragPosition.Y - reference.HitObject.OriginPosition.Y; + 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 = targetPosition - referenceParent.DrawHeight; - float movementDelta = targetPosition - reference.HitObject.Position.Y; + float movementDelta = targetPosition - reference.DrawableObject.Position.Y; foreach (var b in SelectedBlueprints.OfType()) - b.HitObject.Y += movementDelta; + 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.DrawableObject.Parent.DrawHeight; + foreach (var b in SelectedBlueprints) { - var hitObject = b.HitObject; - + var hitObject = b.DrawableObject; var objectParent = (HitObjectContainer)hitObject.Parent; - // Using the hitobject position is required since AdjustPosition can be invoked multiple times per frame - // without the position having been updated by the parenting ScrollingHitObjectContainer - hitObject.Y += moveEvent.InstantDelta.Y; + // 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; + float targetPosition = hitObject.Position.Y; - // If we're scrolling downwards, a position of 0 is actually further away from the hit target - // so we need to flip the vertical coordinate in the hitobject container's space + // 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 = -hitObject.Position.Y; - else - targetPosition = hitObject.Position.Y; + targetPosition = -targetPosition; objectParent.Remove(hitObject); diff --git a/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs index 30b0f09a94..ff8882124f 100644 --- a/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs @@ -9,8 +9,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Masks { public abstract class ManiaSelectionBlueprint : SelectionBlueprint { - protected ManiaSelectionBlueprint(DrawableHitObject hitObject) - : base(hitObject) + protected ManiaSelectionBlueprint(DrawableHitObject drawableObject) + : base(drawableObject) { RelativeSizeAxes = Axes.None; } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index c5c157608f..87b9633c80 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -2,13 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System.Diagnostics; -using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI.Scrolling; @@ -22,8 +21,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { public override bool DisplayResult => false; - public readonly DrawableNote Head; - public readonly DrawableNote Tail; + public DrawableNote Head => headContainer.Child; + public DrawableNote Tail => tailContainer.Child; + + private readonly Container headContainer; + private readonly Container tailContainer; + private readonly Container tickContainer; private readonly BodyPiece bodyPiece; @@ -40,50 +43,81 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public DrawableHoldNote(HoldNote hitObject) : base(hitObject) { - Container tickContainer; RelativeSizeAxes = Axes.X; AddRangeInternal(new Drawable[] { - bodyPiece = new BodyPiece - { - RelativeSizeAxes = Axes.X, - }, - tickContainer = new Container - { - RelativeSizeAxes = Axes.Both, - ChildrenEnumerable = HitObject.NestedHitObjects.OfType().Select(tick => new DrawableHoldNoteTick(tick) - { - HoldStartTime = () => holdStartTime - }) - }, - Head = new DrawableHeadNote(this) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre - }, - Tail = new DrawableTailNote(this) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre - } + bodyPiece = new BodyPiece { RelativeSizeAxes = Axes.X }, + tickContainer = new Container { RelativeSizeAxes = Axes.Both }, + headContainer = new Container { RelativeSizeAxes = Axes.Both }, + tailContainer = new Container { RelativeSizeAxes = Axes.Both }, }); - foreach (var tick in tickContainer) - AddNested(tick); - - AddNested(Head); - AddNested(Tail); - AccentColour.BindValueChanged(colour => { bodyPiece.AccentColour = colour.NewValue; - Head.AccentColour.Value = colour.NewValue; - Tail.AccentColour.Value = colour.NewValue; - tickContainer.ForEach(t => t.AccentColour.Value = colour.NewValue); }, true); } + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + + switch (hitObject) + { + case DrawableHeadNote head: + headContainer.Child = head; + break; + + case DrawableTailNote tail: + tailContainer.Child = tail; + break; + + case DrawableHoldNoteTick tick: + tickContainer.Add(tick); + break; + } + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + headContainer.Clear(); + tailContainer.Clear(); + tickContainer.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case TailNote _: + return new DrawableTailNote(this) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AccentColour = { BindTarget = AccentColour } + }; + + case Note _: + return new DrawableHeadNote(this) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AccentColour = { BindTarget = AccentColour } + }; + + case HoldNoteTick tick: + return new DrawableHoldNoteTick(tick) + { + HoldStartTime = () => holdStartTime, + AccentColour = { BindTarget = AccentColour } + }; + } + + return base.CreateNestedHitObject(hitObject); + } + protected override void OnDirectionChanged(ValueChangedEvent e) { base.OnDirectionChanged(e); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleSelectionBlueprint.cs index d4cdabdb07..0ecce42e88 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleSelectionBlueprint.cs @@ -52,12 +52,19 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("blueprint positioned over hitobject", () => blueprint.CirclePiece.Position == hitCircle.Position); } + [Test] + public void TestStackedHitObject() + { + AddStep("set stacking", () => hitCircle.StackHeight = 5); + AddAssert("blueprint positioned over hitobject", () => blueprint.CirclePiece.Position == hitCircle.StackedPosition); + } + private class TestBlueprint : HitCircleSelectionBlueprint { public new HitCirclePiece CirclePiece => base.CirclePiece; - public TestBlueprint(DrawableHitCircle hitCircle) - : base(hitCircle) + public TestBlueprint(DrawableHitCircle drawableCircle) + : base(drawableCircle) { } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuDistanceSnapGrid.cs new file mode 100644 index 0000000000..da7708081b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuDistanceSnapGrid.cs @@ -0,0 +1,210 @@ +// 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.Framework.MathUtils; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneOsuDistanceSnapGrid : ManualInputManagerTestScene + { + private const double beat_length = 100; + private static readonly Vector2 grid_position = new Vector2(512, 384); + + [Cached(typeof(IEditorBeatmap))] + private readonly EditorBeatmap editorBeatmap; + + [Cached] + private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); + + private TestOsuDistanceSnapGrid grid; + + public TestSceneOsuDistanceSnapGrid() + { + editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + + createGrid(); + } + + [SetUp] + public void Setup() => Schedule(() => + { + Clear(); + + editorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 1; + editorBeatmap.ControlPointInfo.DifficultyPoints.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 TestBeatDivisor(int divisor) + { + AddStep($"set beat divisor = {divisor}", () => beatDivisor.Value = divisor); + createGrid(); + } + + [TestCase(100, 100)] + [TestCase(200, 100)] + public void TestBeatLength(float beatLength, float expectedSpacing) + { + AddStep($"set beat length = {beatLength}", () => + { + editorBeatmap.ControlPointInfo.TimingPoints.Clear(); + editorBeatmap.ControlPointInfo.TimingPoints.Add(new TimingControlPoint { BeatLength = beatLength }); + }); + + createGrid(); + AddAssert($"spacing = {expectedSpacing}", () => Precision.AlmostEquals(expectedSpacing, grid.DistanceSpacing)); + } + + [TestCase(0.5f, 50)] + [TestCase(1, 100)] + [TestCase(1.5f, 150)] + public void TestSpeedMultiplier(float multiplier, float expectedSpacing) + { + AddStep($"set speed multiplier = {multiplier}", () => + { + editorBeatmap.ControlPointInfo.DifficultyPoints.Clear(); + editorBeatmap.ControlPointInfo.DifficultyPoints.Add(new DifficultyControlPoint { SpeedMultiplier = multiplier }); + }); + + createGrid(); + AddAssert($"spacing = {expectedSpacing}", () => Precision.AlmostEquals(expectedSpacing, grid.DistanceSpacing)); + } + + [TestCase(0.5f, 50)] + [TestCase(1, 100)] + [TestCase(1.5f, 150)] + public void TestSliderMultiplier(float multiplier, float expectedSpacing) + { + AddStep($"set speed multiplier = {multiplier}", () => editorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = multiplier); + createGrid(); + AddAssert($"spacing = {expectedSpacing}", () => Precision.AlmostEquals(expectedSpacing, grid.DistanceSpacing)); + } + + [Test] + public void TestCursorInCentre() + { + createGrid(); + + AddStep("move mouse to centre", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position))); + assertSnappedDistance((float)beat_length); + } + + [Test] + public void TestCursorBeforeMovementPoint() + { + createGrid(); + + AddStep("move mouse to just before movement point", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2((float)beat_length, 0) * 1.49f))); + assertSnappedDistance((float)beat_length); + } + + [Test] + public void TestCursorAfterMovementPoint() + { + createGrid(); + + AddStep("move mouse to just after movement point", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2((float)beat_length, 0) * 1.51f))); + assertSnappedDistance((float)beat_length * 2); + } + + private void assertSnappedDistance(float expectedDistance) => AddAssert($"snap distance = {expectedDistance}", () => + { + Vector2 snappedPosition = grid.GetSnapPosition(grid.ToLocalSpace(InputManager.CurrentState.Mouse.Position)); + float distance = Vector2.Distance(snappedPosition, grid_position); + + return Precision.AlmostEquals(expectedDistance, distance); + }); + + private void createGrid() + { + AddStep("create grid", () => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.SlateGray + }, + grid = new TestOsuDistanceSnapGrid(new HitCircle { Position = grid_position }), + new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnapPosition(grid.ToLocalSpace(v)) } + }; + }); + } + + 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); + } + } + + private class TestOsuDistanceSnapGrid : OsuDistanceSnapGrid + { + public new float DistanceSpacing => base.DistanceSpacing; + + public TestOsuDistanceSnapGrid(OsuHitObject hitObject) + : base(hitObject) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/BlueprintPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/BlueprintPiece.cs index 95e926fdfa..b9c77d3f56 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/BlueprintPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/BlueprintPiece.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints /// The to reference properties from. public virtual void UpdateFrom(T hitObject) { - Position = hitObject.Position; + Position = hitObject.StackedPosition; } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs index 6c08990ad6..bb47c7e464 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs @@ -30,7 +30,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles protected override bool OnClick(ClickEvent e) { - HitObject.StartTime = EditorClock.CurrentTime; EndPlacement(); return true; } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs index a191dba8ff..093bae854e 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs @@ -1,18 +1,22 @@ // 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.Primitives; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles { public class HitCircleSelectionBlueprint : OsuSelectionBlueprint { + protected new DrawableHitCircle DrawableObject => (DrawableHitCircle)base.DrawableObject; + protected readonly HitCirclePiece CirclePiece; - public HitCircleSelectionBlueprint(DrawableHitCircle hitCircle) - : base(hitCircle) + public HitCircleSelectionBlueprint(DrawableHitCircle drawableCircle) + : base(drawableCircle) { InternalChild = CirclePiece = new HitCirclePiece(); } @@ -23,5 +27,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles CirclePiece.UpdateFrom(HitObject); } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.HitArea.ReceivePositionalInputAt(screenSpacePos); + + public override Quad SelectionQuad => DrawableObject.HitArea.ScreenSpaceDrawQuad; } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs index 2e4b990db8..a864257274 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs @@ -10,10 +10,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints public abstract class OsuSelectionBlueprint : SelectionBlueprint where T : OsuHitObject { - protected new T HitObject => (T)base.HitObject.HitObject; + protected T HitObject => (T)DrawableObject.HitObject; - protected OsuSelectionBlueprint(DrawableHitObject hitObject) - : base(hitObject) + protected OsuSelectionBlueprint(DrawableHitObject drawableObject) + : base(drawableObject) { } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index fc074ef8af..2fb18bf8ba 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -104,8 +104,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private void beginCurve() { BeginPlacement(); - - HitObject.StartTime = EditorClock.CurrentTime; setState(PlacementState.Body); } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs index 8319f49cbc..5525b8936e 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs @@ -41,8 +41,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners } else { - HitObject.StartTime = EditorClock.CurrentTime; - isPlacingEnd = true; piece.FadeTo(1f, 150, Easing.OutQuint); diff --git a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs new file mode 100644 index 0000000000..bc0f76f000 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public class OsuDistanceSnapGrid : CircularDistanceSnapGrid + { + public OsuDistanceSnapGrid(OsuHitObject hitObject) + : base(hitObject, hitObject.StackedEndPosition) + { + Masking = true; + } + + protected override float GetVelocity(double time, ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + { + TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(time); + DifficultyControlPoint difficultyPoint = controlPointInfo.DifficultyPointAt(time); + + double scoringDistance = OsuHitObject.BASE_SCORING_DISTANCE * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier; + + return (float)(scoringDistance / timingPoint.BeatLength); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 1c040e9dee..fcf2772219 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps; 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.Objects.Drawables; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; @@ -52,5 +54,31 @@ namespace osu.Game.Rulesets.Osu.Edit return base.CreateBlueprintFor(hitObject); } + + protected override DistanceSnapGrid CreateDistanceSnapGrid(IEnumerable selectedHitObjects) + { + var objects = selectedHitObjects.ToList(); + + if (objects.Count == 0) + { + var lastObject = EditorBeatmap.HitObjects.LastOrDefault(h => h.StartTime <= EditorClock.CurrentTime); + + if (lastObject == null) + return null; + + return new OsuDistanceSnapGrid(lastObject); + } + else + { + double minTime = objects.Min(h => h.StartTime); + + var lastObject = EditorBeatmap.HitObjects.LastOrDefault(h => h.StartTime < minTime); + + if (lastObject == null) + return null; + + return new OsuDistanceSnapGrid(lastObject); + } + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index bb227d76df..f74f2d7bc5 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -24,14 +24,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private readonly IBindable stackHeightBindable = new Bindable(); private readonly IBindable scaleBindable = new Bindable(); - public OsuAction? HitAction => hitArea.HitAction; + public OsuAction? HitAction => HitArea.HitAction; + public readonly HitReceptor HitArea; + public readonly SkinnableDrawable CirclePiece; private readonly Container scaleContainer; - private readonly HitArea hitArea; - - public SkinnableDrawable CirclePiece { get; } - public DrawableHitCircle(HitCircle h) : base(h) { @@ -48,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Anchor = Anchor.Centre, Children = new Drawable[] { - hitArea = new HitArea + HitArea = new HitReceptor { Hit = () => { @@ -69,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }, }; - Size = hitArea.DrawSize; + Size = HitArea.DrawSize; } [BackgroundDependencyLoader] @@ -153,7 +151,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Expire(true); - hitArea.HitAction = null; + HitArea.HitAction = null; break; case ArmedState.Miss: @@ -172,7 +170,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public Drawable ProxiedLayer => ApproachCircle; - private class HitArea : Drawable, IKeyBindingHandler + public class HitReceptor : Drawable, IKeyBindingHandler { // IsHovered is used public override bool HandlePositionalInput => true; @@ -181,7 +179,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public OsuAction? HitAction; - public HitArea() + public HitReceptor() { Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 9e8ad9851c..6d45bb9ac4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -5,7 +5,6 @@ using osuTK; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; -using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -21,15 +20,19 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableSlider : DrawableOsuHitObject, IDrawableHitObjectWithProxiedApproach { - private readonly Slider slider; - private readonly List components = new List(); - - public readonly DrawableHitCircle HeadCircle; - public readonly DrawableSliderTail TailCircle; + public DrawableSliderHead HeadCircle => headContainer.Child; + public DrawableSliderTail TailCircle => tailContainer.Child; public readonly SnakingSliderBody Body; public readonly SliderBall Ball; + private readonly Container headContainer; + private readonly Container tailContainer; + private readonly Container tickContainer; + private readonly Container repeatContainer; + + private readonly Slider slider; + private readonly IBindable positionBindable = new Bindable(); private readonly IBindable scaleBindable = new Bindable(); private readonly IBindable pathBindable = new Bindable(); @@ -44,14 +47,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Position = s.StackedPosition; - Container ticks; - Container repeatPoints; - InternalChildren = new Drawable[] { Body = new SnakingSliderBody(s), - ticks = new Container { RelativeSizeAxes = Axes.Both }, - repeatPoints = new Container { RelativeSizeAxes = Axes.Both }, + tickContainer = new Container { RelativeSizeAxes = Axes.Both }, + repeatContainer = new Container { RelativeSizeAxes = Axes.Both }, Ball = new SliderBall(s, this) { GetInitialHitAction = () => HeadCircle.HitAction, @@ -60,45 +60,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables AlwaysPresent = true, Alpha = 0 }, - HeadCircle = new DrawableSliderHead(s, s.HeadCircle) - { - OnShake = Shake - }, - TailCircle = new DrawableSliderTail(s, s.TailCircle) + headContainer = new Container { RelativeSizeAxes = Axes.Both }, + tailContainer = new Container { RelativeSizeAxes = Axes.Both }, }; - - components.Add(Body); - components.Add(Ball); - - AddNested(HeadCircle); - - AddNested(TailCircle); - components.Add(TailCircle); - - foreach (var tick in s.NestedHitObjects.OfType()) - { - var drawableTick = new DrawableSliderTick(tick) { Position = tick.Position - s.Position }; - - ticks.Add(drawableTick); - components.Add(drawableTick); - AddNested(drawableTick); - } - - foreach (var repeatPoint in s.NestedHitObjects.OfType()) - { - var drawableRepeatPoint = new DrawableRepeatPoint(repeatPoint, this) { Position = repeatPoint.Position - s.Position }; - - repeatPoints.Add(drawableRepeatPoint); - components.Add(drawableRepeatPoint); - AddNested(drawableRepeatPoint); - } - } - - protected override void UpdateInitialTransforms() - { - base.UpdateInitialTransforms(); - - Body.FadeInFromZero(HitObject.TimeFadeIn); } [BackgroundDependencyLoader] @@ -129,6 +93,67 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }, true); } + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + + switch (hitObject) + { + case DrawableSliderHead head: + headContainer.Child = head; + break; + + case DrawableSliderTail tail: + tailContainer.Child = tail; + break; + + case DrawableSliderTick tick: + tickContainer.Add(tick); + break; + + case DrawableRepeatPoint repeat: + repeatContainer.Add(repeat); + break; + } + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + + headContainer.Clear(); + tailContainer.Clear(); + repeatContainer.Clear(); + tickContainer.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case SliderTailCircle tail: + return new DrawableSliderTail(slider, tail); + + case HitCircle head: + return new DrawableSliderHead(slider, head) { OnShake = Shake }; + + case SliderTick tick: + return new DrawableSliderTick(tick) { Position = tick.Position - slider.Position }; + + case RepeatPoint repeat: + return new DrawableRepeatPoint(repeat, this) { Position = repeat.Position - slider.Position }; + } + + return base.CreateNestedHitObject(hitObject); + } + + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + Body.FadeInFromZero(HitObject.TimeFadeIn); + } + public readonly Bindable Tracking = new Bindable(); protected override void Update() @@ -139,9 +164,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables double completionProgress = MathHelper.Clamp((Time.Current - slider.StartTime) / slider.Duration, 0, 1); - foreach (var c in components.OfType()) c.UpdateProgress(completionProgress); - foreach (var c in components.OfType()) c.UpdateSnakingPosition(slider.Path.PositionAt(Body.SnakedStart ?? 0), slider.Path.PositionAt(Body.SnakedEnd ?? 0)); - foreach (var t in components.OfType()) t.Tracking = Ball.Tracking; + Ball.UpdateProgress(completionProgress); + Body.UpdateProgress(completionProgress); + + foreach (DrawableHitObject hitObject in NestedHitObjects) + { + if (hitObject is ITrackSnaking s) s.UpdateSnakingPosition(slider.Path.PositionAt(Body.SnakedStart ?? 0), slider.Path.PositionAt(Body.SnakedEnd ?? 0)); + if (hitObject is IRequireTracking t) t.Tracking = Ball.Tracking; + } Size = Body.Size; OriginPosition = Body.PathOffset; @@ -187,7 +217,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables ApplyResult(r => { - var judgementsCount = NestedHitObjects.Count(); + var judgementsCount = NestedHitObjects.Count; var judgementsHit = NestedHitObjects.Count(h => h.IsHit); var hitFraction = (double)judgementsHit / judgementsCount; @@ -228,7 +258,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } } - public Drawable ProxiedLayer => HeadCircle.ApproachCircle; + public Drawable ProxiedLayer => HeadCircle.ProxiedLayer; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Body.ReceivePositionalInputAt(screenSpacePos); } diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 80e013fe68..b506c1f918 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -14,8 +14,16 @@ namespace osu.Game.Rulesets.Osu.Objects { public abstract class OsuHitObject : HitObject, IHasComboInformation, IHasPosition { + /// + /// The radius of hit objects (ie. the radius of a ). + /// public const float OBJECT_RADIUS = 64; + /// + /// Scoring distance with a speed-adjusted beat length of 1 second (ie. the speed slider balls move through their track). + /// + internal const float BASE_SCORING_DISTANCE = 100; + public double TimePreempt = 600; public double TimeFadeIn = 400; diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 9bed123465..d98d72331a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -19,11 +19,6 @@ namespace osu.Game.Rulesets.Osu.Objects { public class Slider : OsuHitObject, IHasCurve { - /// - /// Scoring distance with a speed-adjusted beat length of 1 second. - /// - private const float base_scoring_distance = 100; - public double EndTime => StartTime + this.SpanCount() * Path.Distance / Velocity; public double Duration => EndTime - StartTime; @@ -123,7 +118,7 @@ namespace osu.Game.Rulesets.Osu.Objects TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); DifficultyControlPoint difficultyPoint = controlPointInfo.DifficultyPointAt(StartTime); - double scoringDistance = base_scoring_distance * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier; + double scoringDistance = BASE_SCORING_DISTANCE * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier; Velocity = scoringDistance / timingPoint.BeatLength; TickDistance = scoringDistance / difficulty.SliderTickRate * TickDistanceMultiplier; diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs index 41a02deaca..0aa8661fd3 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs @@ -2,14 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; -using osu.Game.Beatmaps; -using osu.Game.Configuration; using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Skinning; using osuTK; @@ -23,12 +20,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private bool cursorExpand; - private Bindable cursorScale; - private Bindable autoCursorScale; - private readonly IBindable beatmap = new Bindable(); - private Container expandTarget; - private Drawable scaleTarget; public OsuCursor() { @@ -43,43 +35,19 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, IBindable beatmap) + private void load() { InternalChild = expandTarget = new Container { RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, Anchor = Anchor.Centre, - Child = scaleTarget = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.Cursor), _ => new DefaultCursor(), confineMode: ConfineMode.NoScaling) + Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.Cursor), _ => new DefaultCursor(), confineMode: ConfineMode.NoScaling) { Origin = Anchor.Centre, Anchor = Anchor.Centre, } }; - - this.beatmap.BindTo(beatmap); - this.beatmap.ValueChanged += _ => calculateScale(); - - cursorScale = config.GetBindable(OsuSetting.GameplayCursorSize); - cursorScale.ValueChanged += _ => calculateScale(); - - autoCursorScale = config.GetBindable(OsuSetting.AutoCursorSize); - autoCursorScale.ValueChanged += _ => calculateScale(); - - calculateScale(); - } - - private void calculateScale() - { - float scale = cursorScale.Value; - - if (autoCursorScale.Value && beatmap.Value != null) - { - // if we have a beatmap available, let's get its circle size to figure out an automatic cursor scale modifier. - scale *= 1f - 0.7f * (1f + beatmap.Value.BeatmapInfo.BaseDifficulty.CircleSize - BeatmapDifficulty.DEFAULT_DIFFICULTY) / BeatmapDifficulty.DEFAULT_DIFFICULTY; - } - - scaleTarget.Scale = new Vector2(scale); } private const float pressed_scale = 1.2f; diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index 6dbdf0114d..6433ced624 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -8,6 +8,8 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; +using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.UI; using osu.Game.Skinning; @@ -27,6 +29,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private readonly Drawable cursorTrail; + public Bindable CursorScale; + private Bindable userCursorScale; + private Bindable autoCursorScale; + private readonly IBindable beatmap = new Bindable(); + public OsuCursorContainer() { InternalChild = fadeContainer = new Container @@ -37,9 +44,36 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor } [BackgroundDependencyLoader(true)] - private void load(OsuRulesetConfigManager config) + private void load(OsuConfigManager config, OsuRulesetConfigManager rulesetConfig, IBindable beatmap) { - config?.BindWith(OsuRulesetSetting.ShowCursorTrail, showTrail); + rulesetConfig?.BindWith(OsuRulesetSetting.ShowCursorTrail, showTrail); + + this.beatmap.BindTo(beatmap); + this.beatmap.ValueChanged += _ => calculateScale(); + + userCursorScale = config.GetBindable(OsuSetting.GameplayCursorSize); + userCursorScale.ValueChanged += _ => calculateScale(); + + autoCursorScale = config.GetBindable(OsuSetting.AutoCursorSize); + autoCursorScale.ValueChanged += _ => calculateScale(); + + CursorScale = new Bindable(); + CursorScale.ValueChanged += e => ActiveCursor.Scale = cursorTrail.Scale = new Vector2(e.NewValue); + + calculateScale(); + } + + private void calculateScale() + { + float scale = userCursorScale.Value; + + if (autoCursorScale.Value && beatmap.Value != null) + { + // if we have a beatmap available, let's get its circle size to figure out an automatic cursor scale modifier. + scale *= 1f - 0.7f * (1f + beatmap.Value.BeatmapInfo.BaseDifficulty.CircleSize - BeatmapDifficulty.DEFAULT_DIFFICULTY) / BeatmapDifficulty.DEFAULT_DIFFICULTY; + } + + CursorScale.Value = scale; } protected override void LoadComplete() @@ -95,13 +129,13 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor protected override void PopIn() { fadeContainer.FadeTo(1, 300, Easing.OutQuint); - ActiveCursor.ScaleTo(1, 400, Easing.OutQuint); + ActiveCursor.ScaleTo(CursorScale.Value, 400, Easing.OutQuint); } protected override void PopOut() { fadeContainer.FadeTo(0.05f, 450, Easing.OutQuint); - ActiveCursor.ScaleTo(0.8f, 450, Easing.OutQuint); + ActiveCursor.ScaleTo(CursorScale.Value * 0.8f, 450, Easing.OutQuint); } private class DefaultCursorTrail : CursorTrail diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index d1757de445..69e53d6eea 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -57,21 +57,15 @@ namespace osu.Game.Rulesets.Osu.UI public override void Add(DrawableHitObject h) { h.OnNewResult += onNewResult; - - if (h is IDrawableHitObjectWithProxiedApproach c) + h.OnLoadComplete += d => { - var original = c.ProxiedLayer; - - // Hitobjects only have lifetimes set on LoadComplete. For nested hitobjects (e.g. SliderHeads), this only happens when the parenting slider becomes visible. - // This delegation is required to make sure that the approach circles for those not-yet-loaded objects aren't added prematurely. - original.OnLoadComplete += addApproachCircleProxy; - } + if (d is IDrawableHitObjectWithProxiedApproach c) + approachCircles.Add(c.ProxiedLayer.CreateProxy()); + }; base.Add(h); } - private void addApproachCircleProxy(Drawable d) => approachCircles.Add(d.CreateProxy()); - public override void PostProcess() { connectionLayer.HitObjects = HitObjectContainer.Objects.Select(d => d.HitObject).OfType(); diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index 9e5df0d6b1..8347d255fa 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -1,15 +1,15 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Rulesets.Osu.UI.Cursor; -using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; using osuTK; using osuTK.Graphics; @@ -18,9 +18,11 @@ namespace osu.Game.Rulesets.Osu.UI { public class OsuResumeOverlay : ResumeOverlay { + private Container cursorScaleContainer; private OsuClickToResumeCursor clickToResumeCursor; - private GameplayCursorContainer localCursorContainer; + private OsuCursorContainer localCursorContainer; + private Bindable localCursorScale; public override CursorContainer LocalCursor => State.Value == Visibility.Visible ? localCursorContainer : null; @@ -29,22 +31,35 @@ namespace osu.Game.Rulesets.Osu.UI [BackgroundDependencyLoader] private void load() { - Add(clickToResumeCursor = new OsuClickToResumeCursor { ResumeRequested = Resume }); + Add(cursorScaleContainer = new Container + { + RelativePositionAxes = Axes.Both, + Child = clickToResumeCursor = new OsuClickToResumeCursor { ResumeRequested = Resume } + }); } public override void Show() { base.Show(); - clickToResumeCursor.ShowAt(GameplayCursor.ActiveCursor.Position); + GameplayCursor.ActiveCursor.Hide(); + cursorScaleContainer.MoveTo(GameplayCursor.ActiveCursor.Position); + clickToResumeCursor.Appear(); if (localCursorContainer == null) + { Add(localCursorContainer = new OsuCursorContainer()); + + localCursorScale = new Bindable(); + localCursorScale.BindTo(localCursorContainer.CursorScale); + localCursorScale.BindValueChanged(scale => cursorScaleContainer.Scale = new Vector2(scale.NewValue), true); + } } public override void Hide() { localCursorContainer?.Expire(); localCursorContainer = null; + GameplayCursor.ActiveCursor.Show(); base.Hide(); } @@ -82,7 +97,7 @@ namespace osu.Game.Rulesets.Osu.UI case OsuAction.RightButton: if (!IsHovered) return false; - this.ScaleTo(new Vector2(2), TRANSITION_TIME, Easing.OutQuint); + this.ScaleTo(2, TRANSITION_TIME, Easing.OutQuint); ResumeRequested?.Invoke(); return true; @@ -93,11 +108,10 @@ namespace osu.Game.Rulesets.Osu.UI public bool OnReleased(OsuAction action) => false; - public void ShowAt(Vector2 activeCursorPosition) => Schedule(() => + public void Appear() => Schedule(() => { updateColour(); - this.MoveTo(activeCursorPosition); - this.ScaleTo(new Vector2(4)).Then().ScaleTo(Vector2.One, 1000, Easing.OutQuint); + this.ScaleTo(4).Then().ScaleTo(1, 1000, Easing.OutQuint); }); private void updateColour() diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs index cbbf5b0c09..eaa8ca7ebb 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs @@ -239,7 +239,7 @@ namespace osu.Game.Rulesets.Taiko.Tests private class TestStrongNestedHit : DrawableStrongNestedHit { public TestStrongNestedHit(DrawableHitObject mainObject) - : base(null, mainObject) + : base(new StrongHitObject { StartTime = mainObject.HitObject.StartTime }, mainObject) { } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index 8e16a21199..cc0d6829ba 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -12,6 +12,7 @@ using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Objects.Drawables @@ -28,31 +29,18 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// private int rollingHits; + private readonly Container tickContainer; + + private Color4 colourIdle; + private Color4 colourEngaged; + public DrawableDrumRoll(DrumRoll drumRoll) : base(drumRoll) { RelativeSizeAxes = Axes.Y; - - Container tickContainer; MainPiece.Add(tickContainer = new Container { RelativeSizeAxes = Axes.Both }); - - foreach (var tick in drumRoll.NestedHitObjects.OfType()) - { - var newTick = new DrawableDrumRollTick(tick); - newTick.OnNewResult += onNewTickResult; - - AddNested(newTick); - tickContainer.Add(newTick); - } } - protected override TaikoPiece CreateMainPiece() => new ElongatedCirclePiece(); - - public override bool OnPressed(TaikoAction action) => false; - - private Color4 colourIdle; - private Color4 colourEngaged; - [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -60,8 +48,51 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables colourEngaged = colours.YellowDarker; } - private void onNewTickResult(DrawableHitObject obj, JudgementResult result) + protected override void LoadComplete() { + base.LoadComplete(); + + OnNewResult += onNewResult; + } + + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + + switch (hitObject) + { + case DrawableDrumRollTick tick: + tickContainer.Add(tick); + break; + } + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + tickContainer.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case DrumRollTick tick: + return new DrawableDrumRollTick(tick); + } + + return base.CreateNestedHitObject(hitObject); + } + + protected override TaikoPiece CreateMainPiece() => new ElongatedCirclePiece(); + + public override bool OnPressed(TaikoAction action) => false; + + private void onNewResult(DrawableHitObject obj, JudgementResult result) + { + if (!(obj is DrawableDrumRollTick)) + return; + if (result.Type > HitResult.Miss) rollingHits++; else diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index 07af7fe7e0..9c9dfc5f9e 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -2,7 +2,6 @@ // 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.Extensions.Color4Extensions; @@ -14,6 +13,7 @@ using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Objects.Drawables @@ -30,8 +30,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// private const double ring_appear_offset = 100; - private readonly List ticks = new List(); - + private readonly Container ticks; private readonly Container bodyContainer; private readonly CircularContainer targetRing; private readonly CircularContainer expandingRing; @@ -108,16 +107,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } }); + AddInternal(ticks = new Container { RelativeSizeAxes = Axes.Both }); + MainPiece.Add(symbol = new SwellSymbolPiece()); - - foreach (var tick in HitObject.NestedHitObjects.OfType()) - { - var vis = new DrawableSwellTick(tick); - - ticks.Add(vis); - AddInternal(vis); - AddNested(vis); - } } [BackgroundDependencyLoader] @@ -136,11 +128,49 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Width *= Parent.RelativeChildSize.X; } + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + + switch (hitObject) + { + case DrawableSwellTick tick: + ticks.Add(tick); + break; + } + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + ticks.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case SwellTick tick: + return new DrawableSwellTick(tick); + } + + return base.CreateNestedHitObject(hitObject); + } + protected override void CheckForResult(bool userTriggered, double timeOffset) { if (userTriggered) { - var nextTick = ticks.Find(j => !j.IsHit); + DrawableSwellTick nextTick = null; + + foreach (var t in ticks) + { + if (!t.IsHit) + { + nextTick = t; + break; + } + } nextTick?.TriggerResult(HitResult.Great); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index 423f65b2d3..0db6498c12 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -11,6 +11,7 @@ using osu.Game.Audio; using System.Collections.Generic; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; +using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -109,11 +110,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { public override Vector2 OriginPosition => new Vector2(DrawHeight / 2); - protected readonly Vector2 BaseSize; + public new TaikoHitType HitObject; + protected readonly Vector2 BaseSize; protected readonly TaikoPiece MainPiece; - public new TaikoHitType HitObject; + private readonly Container strongHitContainer; protected DrawableTaikoHitObject(TaikoHitType hitObject) : base(hitObject) @@ -129,17 +131,38 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Content.Add(MainPiece = CreateMainPiece()); MainPiece.KiaiMode = HitObject.Kiai; - var strongObject = HitObject.NestedHitObjects.OfType().FirstOrDefault(); + AddInternal(strongHitContainer = new Container()); + } - if (strongObject != null) + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + + switch (hitObject) { - var strongHit = CreateStrongHit(strongObject); - - AddNested(strongHit); - AddInternal(strongHit); + case DrawableStrongNestedHit strong: + strongHitContainer.Add(strong); + break; } } + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + strongHitContainer.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case StrongHitObject strong: + return CreateStrongHit(strong); + } + + return base.CreateNestedHitObject(hitObject); + } + // Normal and clap samples are handled by the drum protected override IEnumerable GetSamples() => HitObject.Samples.Where(s => s.Name != HitSampleInfo.HIT_NORMAL && s.Name != HitSampleInfo.HIT_CLAP); diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs new file mode 100644 index 0000000000..6d7159a825 --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -0,0 +1,143 @@ +// 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 NUnit.Framework; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; +using osuTK.Graphics; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneHitObjectAccentColour : OsuTestScene + { + private Container skinContainer; + + [SetUp] + public void Setup() => Schedule(() => Child = skinContainer = new SkinProvidingContainer(new TestSkin())); + + [Test] + public void TestChangeComboIndexBeforeLoad() + { + TestDrawableHitObject hitObject = null; + + AddStep("set combo and add hitobject", () => + { + hitObject = new TestDrawableHitObject(); + hitObject.HitObject.ComboIndex = 1; + + skinContainer.Add(hitObject); + }); + + AddAssert("combo colour is green", () => hitObject.AccentColour.Value == Color4.Green); + } + + [Test] + public void TestChangeComboIndexDuringLoad() + { + TestDrawableHitObject hitObject = null; + + AddStep("add hitobject and set combo", () => + { + skinContainer.Add(hitObject = new TestDrawableHitObject()); + hitObject.HitObject.ComboIndex = 1; + }); + + AddAssert("combo colour is green", () => hitObject.AccentColour.Value == Color4.Green); + } + + [Test] + public void TestChangeComboIndexAfterLoad() + { + TestDrawableHitObject hitObject = null; + + AddStep("add hitobject", () => skinContainer.Add(hitObject = new TestDrawableHitObject())); + AddAssert("combo colour is red", () => hitObject.AccentColour.Value == Color4.Red); + + AddStep("change combo", () => hitObject.HitObject.ComboIndex = 1); + AddAssert("combo colour is green", () => hitObject.AccentColour.Value == Color4.Green); + } + + private class TestDrawableHitObject : DrawableHitObject + { + public TestDrawableHitObject() + : base(new TestHitObjectWithCombo()) + { + } + } + + private class TestHitObjectWithCombo : HitObject, IHasComboInformation + { + public bool NewCombo { get; } = false; + public int ComboOffset { get; } = 0; + + public Bindable IndexInCurrentComboBindable { get; } = new Bindable(); + + public int IndexInCurrentCombo + { + get => IndexInCurrentComboBindable.Value; + set => IndexInCurrentComboBindable.Value = value; + } + + public Bindable ComboIndexBindable { get; } = new Bindable(); + + public int ComboIndex + { + get => ComboIndexBindable.Value; + set => ComboIndexBindable.Value = value; + } + + public Bindable LastInComboBindable { get; } = new Bindable(); + + public bool LastInCombo + { + get => LastInComboBindable.Value; + set => LastInComboBindable.Value = value; + } + } + + private class TestSkin : ISkin + { + public readonly List ComboColours = new List + { + Color4.Red, + Color4.Green + }; + + public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotImplementedException(); + + public Texture GetTexture(string componentName) => throw new NotImplementedException(); + + public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + + public IBindable GetConfig(TLookup lookup) + { + switch (lookup) + { + case GlobalSkinConfiguration global: + switch (global) + { + case GlobalSkinConfiguration.ComboColours: + return SkinUtils.As(new Bindable>(ComboColours)); + } + + break; + } + + throw new NotImplementedException(); + } + } + } +} diff --git a/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs new file mode 100644 index 0000000000..42a3b4cf43 --- /dev/null +++ b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class BeatmapSetInfoEqualityTest + { + [Test] + public void TestOnlineWithOnline() + { + var ourInfo = new BeatmapSetInfo { OnlineBeatmapSetID = 123 }; + var otherInfo = new BeatmapSetInfo { OnlineBeatmapSetID = 123 }; + + Assert.AreEqual(ourInfo, otherInfo); + } + + [Test] + public void TestDatabasedWithDatabased() + { + var ourInfo = new BeatmapSetInfo { ID = 123 }; + var otherInfo = new BeatmapSetInfo { ID = 123 }; + + Assert.AreEqual(ourInfo, otherInfo); + } + + [Test] + public void TestDatabasedWithOnline() + { + var ourInfo = new BeatmapSetInfo { ID = 123, OnlineBeatmapSetID = 12 }; + var otherInfo = new BeatmapSetInfo { OnlineBeatmapSetID = 12 }; + + Assert.AreEqual(ourInfo, otherInfo); + } + + [Test] + public void TestCheckNullID() + { + var ourInfo = new BeatmapSetInfo { Status = BeatmapSetOnlineStatus.Loved }; + var otherInfo = new BeatmapSetInfo { Status = BeatmapSetOnlineStatus.Approved }; + + Assert.AreNotEqual(ourInfo, otherInfo); + } + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs index 3061a3a542..f858174ff2 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs @@ -285,6 +285,12 @@ namespace osu.Game.Tests.Visual.Background }); } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + rulesets?.Dispose(); + } + private class DummySongSelect : PlaySongSelect { protected override BackgroundScreen CreateBackground() diff --git a/osu.Game.Tests/Visual/Editor/TestSceneBeatSnapGrid.cs b/osu.Game.Tests/Visual/Editor/TestSceneDistanceSnapGrid.cs similarity index 93% rename from osu.Game.Tests/Visual/Editor/TestSceneBeatSnapGrid.cs rename to osu.Game.Tests/Visual/Editor/TestSceneDistanceSnapGrid.cs index 073cec7315..a9e5930478 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneBeatSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editor/TestSceneDistanceSnapGrid.cs @@ -19,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Editor { - public class TestSceneBeatSnapGrid : EditorClockTestScene + public class TestSceneDistanceSnapGrid : EditorClockTestScene { private const double beat_length = 100; private static readonly Vector2 grid_position = new Vector2(512, 384); @@ -27,9 +27,9 @@ namespace osu.Game.Tests.Visual.Editor [Cached(typeof(IEditorBeatmap))] private readonly EditorBeatmap editorBeatmap; - private TestBeatSnapGrid grid; + private TestDistanceSnapGrid grid; - public TestSceneBeatSnapGrid() + public TestSceneDistanceSnapGrid() { editorBeatmap = new EditorBeatmap(new OsuBeatmap()); editorBeatmap.ControlPointInfo.TimingPoints.Add(new TimingControlPoint { BeatLength = beat_length }); @@ -112,7 +112,7 @@ namespace osu.Game.Tests.Visual.Editor 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) + private void createGrid(Action func = null, string description = null) { AddStep($"create grid {description ?? string.Empty}", () => { @@ -123,20 +123,20 @@ namespace osu.Game.Tests.Visual.Editor RelativeSizeAxes = Axes.Both, Colour = Color4.SlateGray }, - grid = new TestBeatSnapGrid(new HitObject(), grid_position) + grid = new TestDistanceSnapGrid(new HitObject(), grid_position) }; func?.Invoke(grid); }); } - private class TestBeatSnapGrid : BeatSnapGrid + private class TestDistanceSnapGrid : DistanceSnapGrid { public new float Velocity = 1; public new float DistanceSpacing => base.DistanceSpacing; - public TestBeatSnapGrid(HitObject hitObject, Vector2 centrePosition) + public TestDistanceSnapGrid(HitObject hitObject, Vector2 centrePosition) : base(hitObject, centrePosition) { } diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs new file mode 100644 index 0000000000..436e80d6f5 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs @@ -0,0 +1,58 @@ +// 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 NUnit.Framework; +using osu.Game.Online.API.Requests; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Overlays.Comments; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneCommentsContainer : OsuTestScene + { + public override IReadOnlyList RequiredTypes => new[] + { + typeof(CommentsContainer), + typeof(CommentsHeader), + typeof(DrawableComment), + typeof(HeaderButton), + typeof(SortTabControl), + typeof(ShowChildrenButton), + typeof(DeletedChildrenPlaceholder) + }; + + protected override bool UseOnlineAPI => true; + + public TestSceneCommentsContainer() + { + BasicScrollContainer scrollFlow; + + Add(scrollFlow = new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + }); + + AddStep("Big Black comments", () => + { + scrollFlow.Clear(); + scrollFlow.Add(new CommentsContainer(CommentableType.Beatmapset, 41823)); + }); + + AddStep("Airman comments", () => + { + scrollFlow.Clear(); + scrollFlow.Add(new CommentsContainer(CommentableType.Beatmapset, 24313)); + }); + + AddStep("lazer build comments", () => + { + scrollFlow.Clear(); + scrollFlow.Add(new CommentsContainer(CommentableType.Build, 4772)); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs index bccb263600..b9fbbfef6b 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Overlays.Profile.Sections; using System; using System.Collections.Generic; using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Allocation; +using osu.Game.Graphics; namespace osu.Game.Tests.Visual.Online { @@ -17,11 +19,11 @@ namespace osu.Game.Tests.Visual.Online public TestSceneShowMoreButton() { - ShowMoreButton button = null; + TestButton button = null; int fireCount = 0; - Add(button = new ShowMoreButton + Add(button = new TestButton { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -51,5 +53,16 @@ namespace osu.Game.Tests.Visual.Online AddAssert("action fired twice", () => fireCount == 2); AddAssert("is in loading state", () => button.IsLoading); } + + private class TestButton : ShowMoreButton + { + [BackgroundDependencyLoader] + private void load(OsuColour colors) + { + IdleColour = colors.YellowDark; + HoverColour = colors.Yellow; + ChevronIconColour = colors.Red; + } + } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index a7020b6534..efe7fee5e4 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -349,5 +349,11 @@ namespace osu.Game.Tests.Visual.SongSelect DateAdded = DateTimeOffset.UtcNow, }; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + rulesets?.Dispose(); + } } } diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 03bc7c7312..a8b83dca38 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -63,6 +63,21 @@ namespace osu.Game.Beatmaps public bool Protected { get; set; } - public bool Equals(BeatmapSetInfo other) => OnlineBeatmapSetID == other?.OnlineBeatmapSetID; + public bool Equals(BeatmapSetInfo other) + { + if (other == null) + return false; + + if (ID != 0 && other.ID != 0) + return ID == other.ID; + + if (OnlineBeatmapSetID.HasValue && other.OnlineBeatmapSetID.HasValue) + return OnlineBeatmapSetID == other.OnlineBeatmapSetID; + + if (!string.IsNullOrEmpty(Hash) && !string.IsNullOrEmpty(other.Hash)) + return Hash == other.Hash; + + return ReferenceEquals(this, other); + } } } diff --git a/osu.Game/Overlays/Profile/Sections/ShowMoreButton.cs b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs similarity index 80% rename from osu.Game/Overlays/Profile/Sections/ShowMoreButton.cs rename to osu.Game/Graphics/UserInterface/ShowMoreButton.cs index cf4e1c0dde..5296b9dd7f 100644 --- a/osu.Game/Overlays/Profile/Sections/ShowMoreButton.cs +++ b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs @@ -1,30 +1,36 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osuTK; +using osuTK.Graphics; using System.Collections.Generic; -namespace osu.Game.Overlays.Profile.Sections +namespace osu.Game.Graphics.UserInterface { public class ShowMoreButton : OsuHoverContainer { private const float fade_duration = 200; - private readonly Box background; - private readonly LoadingAnimation loading; - private readonly FillFlowContainer content; + private Color4 chevronIconColour; - protected override IEnumerable EffectTargets => new[] { background }; + protected Color4 ChevronIconColour + { + get => chevronIconColour; + set => chevronIconColour = leftChevron.Colour = rightChevron.Colour = value; + } + + public string Text + { + get => text.Text; + set => text.Text = value; + } private bool isLoading; @@ -33,26 +39,32 @@ namespace osu.Game.Overlays.Profile.Sections get => isLoading; set { - if (isLoading == value) - return; - isLoading = value; Enabled.Value = !isLoading; if (value) { - loading.FadeIn(fade_duration, Easing.OutQuint); + loading.Show(); content.FadeOut(fade_duration, Easing.OutQuint); } else { - loading.FadeOut(fade_duration, Easing.OutQuint); + loading.Hide(); content.FadeIn(fade_duration, Easing.OutQuint); } } } + private readonly Box background; + private readonly LoadingAnimation loading; + private readonly FillFlowContainer content; + private readonly ChevronIcon leftChevron; + private readonly ChevronIcon rightChevron; + private readonly SpriteText text; + + protected override IEnumerable EffectTargets => new[] { background }; + public ShowMoreButton() { AutoSizeAxes = Axes.Both; @@ -77,15 +89,15 @@ namespace osu.Game.Overlays.Profile.Sections Spacing = new Vector2(7), Children = new Drawable[] { - new ChevronIcon(), - new OsuSpriteText + leftChevron = new ChevronIcon(), + text = new OsuSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), Text = "show more".ToUpper(), }, - new ChevronIcon(), + rightChevron = new ChevronIcon(), } }, loading = new LoadingAnimation @@ -99,13 +111,6 @@ namespace osu.Game.Overlays.Profile.Sections }; } - [BackgroundDependencyLoader] - private void load(OsuColour colors) - { - IdleColour = colors.GreySeafoamDark; - HoverColour = colors.GreySeafoam; - } - protected override bool OnClick(ClickEvent e) { if (!Enabled.Value) @@ -133,12 +138,6 @@ namespace osu.Game.Overlays.Profile.Sections Size = new Vector2(icon_size); Icon = FontAwesome.Solid.ChevronDown; } - - [BackgroundDependencyLoader] - private void load(OsuColour colors) - { - Colour = colors.Yellow; - } } } } diff --git a/osu.Game/Online/API/Requests/GetCommentsRequest.cs b/osu.Game/Online/API/Requests/GetCommentsRequest.cs new file mode 100644 index 0000000000..7763501860 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetCommentsRequest.cs @@ -0,0 +1,47 @@ +// 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.IO.Network; +using Humanizer; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Comments; + +namespace osu.Game.Online.API.Requests +{ + public class GetCommentsRequest : APIRequest + { + private readonly long id; + private readonly int page; + private readonly CommentableType type; + private readonly CommentsSortCriteria sort; + + public GetCommentsRequest(CommentableType type, long id, CommentsSortCriteria sort = CommentsSortCriteria.New, int page = 1) + { + this.type = type; + this.sort = sort; + this.id = id; + this.page = page; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.AddParameter("commentable_type", type.ToString().Underscore().ToLowerInvariant()); + req.AddParameter("commentable_id", id.ToString()); + req.AddParameter("sort", sort.ToString().ToLowerInvariant()); + req.AddParameter("page", page.ToString()); + + return req; + } + + protected override string Target => "comments"; + } + + public enum CommentableType + { + Build, + Beatmapset, + NewsPost + } +} diff --git a/osu.Game/Online/API/Requests/Responses/Comment.cs b/osu.Game/Online/API/Requests/Responses/Comment.cs new file mode 100644 index 0000000000..29abaa74e5 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/Comment.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using osu.Game.Users; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class Comment + { + [JsonProperty(@"id")] + public long Id { get; set; } + + [JsonProperty(@"parent_id")] + public long? ParentId { get; set; } + + public readonly List ChildComments = new List(); + + public Comment ParentComment { get; set; } + + [JsonProperty(@"user_id")] + public long? UserId { get; set; } + + public User User { get; set; } + + [JsonProperty(@"message")] + public string Message { get; set; } + + [JsonProperty(@"message_html")] + public string MessageHtml { get; set; } + + [JsonProperty(@"replies_count")] + public int RepliesCount { get; set; } + + [JsonProperty(@"votes_count")] + public int VotesCount { get; set; } + + [JsonProperty(@"commenatble_type")] + public string CommentableType { get; set; } + + [JsonProperty(@"commentable_id")] + public int CommentableId { get; set; } + + [JsonProperty(@"legacy_name")] + public string LegacyName { get; set; } + + [JsonProperty(@"created_at")] + public DateTimeOffset CreatedAt { get; set; } + + [JsonProperty(@"updated_at")] + public DateTimeOffset? UpdatedAt { get; set; } + + [JsonProperty(@"deleted_at")] + public DateTimeOffset? DeletedAt { get; set; } + + [JsonProperty(@"edited_at")] + public DateTimeOffset? EditedAt { get; set; } + + [JsonProperty(@"edited_by_id")] + public long? EditedById { get; set; } + + public User EditedUser { get; set; } + + public bool IsTopLevel => !ParentId.HasValue; + + public bool IsDeleted => DeletedAt.HasValue; + + public bool HasMessage => !string.IsNullOrEmpty(MessageHtml); + + public string GetMessage => HasMessage ? WebUtility.HtmlDecode(Regex.Replace(MessageHtml, @"<(.|\n)*?>", string.Empty)) : string.Empty; + + public int DeletedChildrenCount => ChildComments.Count(c => c.IsDeleted); + } +} diff --git a/osu.Game/Online/API/Requests/Responses/CommentBundle.cs b/osu.Game/Online/API/Requests/Responses/CommentBundle.cs new file mode 100644 index 0000000000..7063581605 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/CommentBundle.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using osu.Game.Users; +using System.Collections.Generic; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class CommentBundle + { + private List comments; + + [JsonProperty(@"comments")] + public List Comments + { + get => comments; + set + { + comments = value; + comments.ForEach(child => + { + if (child.ParentId != null) + { + comments.ForEach(parent => + { + if (parent.Id == child.ParentId) + { + parent.ChildComments.Add(child); + child.ParentComment = parent; + } + }); + } + }); + } + } + + [JsonProperty(@"has_more")] + public bool HasMore { get; set; } + + [JsonProperty(@"has_more_id")] + public long? HasMoreId { get; set; } + + [JsonProperty(@"user_follow")] + public bool UserFollow { get; set; } + + [JsonProperty(@"included_comments")] + public List IncludedComments { get; set; } + + private List users; + + [JsonProperty(@"users")] + public List Users + { + get => users; + set + { + users = value; + + value.ForEach(u => + { + Comments.ForEach(c => + { + if (c.UserId == u.Id) + c.User = u; + + if (c.EditedById == u.Id) + c.EditedUser = u; + }); + }); + } + } + + [JsonProperty(@"total")] + public int Total { get; set; } + + [JsonProperty(@"top_level_count")] + public int TopLevelCount { get; set; } + } +} diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 8578517a17..194a439b06 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -298,6 +298,12 @@ namespace osu.Game public string[] HandledExtensions => fileImporters.SelectMany(i => i.HandledExtensions).ToArray(); + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + RulesetStore?.Dispose(); + } + private class OsuUserInputManager : UserInputManager { protected override MouseButtonEventManager CreateButtonManagerFor(MouseButton button) diff --git a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs index be417f4aac..f91d2e3323 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs @@ -121,7 +121,7 @@ namespace osu.Game.Overlays.AccountCreation multiAccountExplanationText.AddText("? osu! has a policy of "); multiAccountExplanationText.AddText("one account per person!", cp => cp.Colour = colours.Yellow); multiAccountExplanationText.AddText(" Please be aware that creating more than one account per person may result in "); - multiAccountExplanationText.AddText("permanent deactivation of accounts", cp => cp.Colour = colours.Yellow); + multiAccountExplanationText.AddText("permanent deactivation of accounts", cp => cp.Colour = colours.Yellow); multiAccountExplanationText.AddText("."); furtherAssistance.AddText("Need further assistance? Contact us via our "); diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs new file mode 100644 index 0000000000..abc1b7233d --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -0,0 +1,197 @@ +// 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.Containers; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using System.Threading; +using System.Linq; +using osu.Framework.Extensions.IEnumerableExtensions; + +namespace osu.Game.Overlays.Comments +{ + public class CommentsContainer : CompositeDrawable + { + private readonly CommentableType type; + private readonly long id; + + public readonly Bindable Sort = new Bindable(); + public readonly BindableBool ShowDeleted = new BindableBool(); + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + private GetCommentsRequest request; + private CancellationTokenSource loadCancellation; + private int currentPage; + + private readonly Box background; + private readonly FillFlowContainer content; + private readonly DeletedChildrenPlaceholder deletedChildrenPlaceholder; + private readonly CommentsShowMoreButton moreButton; + + public CommentsContainer(CommentableType type, long id) + { + this.type = type; + this.id = id; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + AddRangeInternal(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new CommentsHeader + { + Sort = { BindTarget = Sort }, + ShowDeleted = { BindTarget = ShowDeleted } + }, + content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(0.2f) + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + deletedChildrenPlaceholder = new DeletedChildrenPlaceholder + { + ShowDeleted = { BindTarget = ShowDeleted } + }, + new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Child = moreButton = new CommentsShowMoreButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding(5), + Action = getComments + } + } + } + } + } + } + } + } + }); + } + + [BackgroundDependencyLoader] + private void load() + { + background.Colour = colours.Gray2; + } + + protected override void LoadComplete() + { + Sort.BindValueChanged(onSortChanged, true); + base.LoadComplete(); + } + + private void onSortChanged(ValueChangedEvent sort) + { + clearComments(); + getComments(); + } + + private void getComments() + { + request?.Cancel(); + loadCancellation?.Cancel(); + request = new GetCommentsRequest(type, id, Sort.Value, currentPage++); + request.Success += onSuccess; + api.Queue(request); + } + + private void clearComments() + { + currentPage = 1; + deletedChildrenPlaceholder.DeletedCount.Value = 0; + moreButton.IsLoading = true; + content.Clear(); + } + + private void onSuccess(CommentBundle response) + { + loadCancellation = new CancellationTokenSource(); + + FillFlowContainer page = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }; + + foreach (var c in response.Comments) + { + if (c.IsTopLevel) + page.Add(new DrawableComment(c) + { + ShowDeleted = { BindTarget = ShowDeleted } + }); + } + + LoadComponentAsync(page, loaded => + { + content.Add(loaded); + + deletedChildrenPlaceholder.DeletedCount.Value += response.Comments.Count(c => c.IsDeleted && c.IsTopLevel); + + if (response.HasMore) + { + int loadedTopLevelComments = 0; + content.Children.OfType().ForEach(p => loadedTopLevelComments += p.Children.OfType().Count()); + + moreButton.Current.Value = response.TopLevelCount - loadedTopLevelComments; + moreButton.IsLoading = false; + } + + moreButton.FadeTo(response.HasMore ? 1 : 0); + }, loadCancellation.Token); + } + + protected override void Dispose(bool isDisposing) + { + request?.Cancel(); + loadCancellation?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs b/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs new file mode 100644 index 0000000000..b0174e7b1a --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs @@ -0,0 +1,32 @@ +// 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.Bindables; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Comments +{ + public class CommentsShowMoreButton : ShowMoreButton + { + public readonly BindableInt Current = new BindableInt(); + + public CommentsShowMoreButton() + { + IdleColour = OsuColour.Gray(0.3f); + HoverColour = OsuColour.Gray(0.4f); + ChevronIconColour = OsuColour.Gray(0.5f); + } + + protected override void LoadComplete() + { + Current.BindValueChanged(onCurrentChanged, true); + base.LoadComplete(); + } + + private void onCurrentChanged(ValueChangedEvent count) + { + Text = $@"Show More ({count.NewValue})".ToUpper(); + } + } +} diff --git a/osu.Game/Overlays/Comments/DeletedChildrenPlaceholder.cs b/osu.Game/Overlays/Comments/DeletedChildrenPlaceholder.cs new file mode 100644 index 0000000000..e849691597 --- /dev/null +++ b/osu.Game/Overlays/Comments/DeletedChildrenPlaceholder.cs @@ -0,0 +1,61 @@ +// 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.Containers; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osu.Framework.Bindables; +using Humanizer; + +namespace osu.Game.Overlays.Comments +{ + public class DeletedChildrenPlaceholder : FillFlowContainer + { + public readonly BindableBool ShowDeleted = new BindableBool(); + public readonly BindableInt DeletedCount = new BindableInt(); + + private readonly SpriteText countText; + + public DeletedChildrenPlaceholder() + { + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Horizontal; + Spacing = new Vector2(3, 0); + Margin = new MarginPadding { Vertical = 10, Left = 80 }; + Children = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.Solid.Trash, + Size = new Vector2(14), + }, + countText = new SpriteText + { + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + } + }; + } + + protected override void LoadComplete() + { + DeletedCount.BindValueChanged(_ => updateDisplay(), true); + ShowDeleted.BindValueChanged(_ => updateDisplay(), true); + base.LoadComplete(); + } + + private void updateDisplay() + { + if (DeletedCount.Value != 0) + { + countText.Text = @"deleted comment".ToQuantity(DeletedCount.Value); + this.FadeTo(ShowDeleted.Value ? 0 : 1); + } + else + { + Hide(); + } + } + } +} diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs new file mode 100644 index 0000000000..89abda92cf --- /dev/null +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -0,0 +1,363 @@ +// 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.Containers; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users.Drawables; +using osu.Game.Graphics.Containers; +using osu.Game.Utils; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Shapes; +using System.Linq; +using osu.Game.Online.Chat; + +namespace osu.Game.Overlays.Comments +{ + public class DrawableComment : CompositeDrawable + { + private const int avatar_size = 40; + private const int margin = 10; + + public readonly BindableBool ShowDeleted = new BindableBool(); + + private readonly BindableBool childrenExpanded = new BindableBool(true); + + private readonly FillFlowContainer childCommentsVisibilityContainer; + private readonly Comment comment; + + public DrawableComment(Comment comment) + { + LinkFlowContainer username; + FillFlowContainer childCommentsContainer; + DeletedChildrenPlaceholder deletedChildrenPlaceholder; + FillFlowContainer info; + LinkFlowContainer message; + GridContainer content; + VotePill votePill; + + this.comment = comment; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(margin), + Child = content = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Horizontal = margin }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + votePill = new VotePill(comment.VotesCount) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AlwaysPresent = true, + }, + new UpdateableAvatar(comment.User) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(avatar_size), + Masking = true, + CornerRadius = avatar_size / 2f, + }, + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 3), + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(7, 0), + Children = new Drawable[] + { + username = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true)) + { + AutoSizeAxes = Axes.Both, + }, + new ParentUsername(comment), + new SpriteText + { + Alpha = comment.IsDeleted ? 1 : 0, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + Text = @"deleted", + } + } + }, + message = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 40 } + }, + info = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Colour = OsuColour.Gray(0.7f), + Children = new Drawable[] + { + new SpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 12), + Text = HumanizerUtils.Humanize(comment.CreatedAt) + }, + new RepliesButton(comment.RepliesCount) + { + Expanded = { BindTarget = childrenExpanded } + }, + } + } + } + } + } + } + } + }, + childCommentsVisibilityContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + childCommentsContainer = new FillFlowContainer + { + Padding = new MarginPadding { Left = 20 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical + }, + deletedChildrenPlaceholder = new DeletedChildrenPlaceholder + { + ShowDeleted = { BindTarget = ShowDeleted } + } + } + } + } + }; + + deletedChildrenPlaceholder.DeletedCount.Value = comment.DeletedChildrenCount; + + if (comment.UserId.HasValue) + username.AddUserLink(comment.User); + else + username.AddText(comment.LegacyName); + + if (comment.EditedAt.HasValue) + { + info.Add(new SpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 12), + Text = $@"edited {HumanizerUtils.Humanize(comment.EditedAt.Value)} by {comment.EditedUser.Username}" + }); + } + + if (comment.HasMessage) + { + var formattedSource = MessageFormatter.FormatText(comment.GetMessage); + message.AddLinks(formattedSource.Text, formattedSource.Links); + } + + if (comment.IsDeleted) + { + content.FadeColour(OsuColour.Gray(0.5f)); + votePill.Hide(); + } + + if (comment.IsTopLevel) + { + AddInternal(new Container + { + RelativeSizeAxes = Axes.X, + Height = 1.5f, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(0.1f) + } + }); + + if (comment.ChildComments.Any()) + { + AddInternal(new ChevronButton(comment) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding { Right = 30, Top = margin }, + Expanded = { BindTarget = childrenExpanded } + }); + } + } + + comment.ChildComments.ForEach(c => childCommentsContainer.Add(new DrawableComment(c) + { + ShowDeleted = { BindTarget = ShowDeleted } + })); + } + + protected override void LoadComplete() + { + ShowDeleted.BindValueChanged(show => + { + if (comment.IsDeleted) + this.FadeTo(show.NewValue ? 1 : 0); + }, true); + childrenExpanded.BindValueChanged(expanded => childCommentsVisibilityContainer.FadeTo(expanded.NewValue ? 1 : 0), true); + base.LoadComplete(); + } + + private class ChevronButton : ShowChildrenButton + { + private readonly SpriteIcon icon; + + public ChevronButton(Comment comment) + { + Alpha = comment.IsTopLevel && comment.ChildComments.Any() ? 1 : 0; + Child = icon = new SpriteIcon + { + Size = new Vector2(12), + Colour = OsuColour.Gray(0.7f) + }; + } + + protected override void OnExpandedChanged(ValueChangedEvent expanded) + { + icon.Icon = expanded.NewValue ? FontAwesome.Solid.ChevronUp : FontAwesome.Solid.ChevronDown; + } + } + + private class RepliesButton : ShowChildrenButton + { + private readonly SpriteText text; + private readonly int count; + + public RepliesButton(int count) + { + this.count = count; + + Alpha = count == 0 ? 0 : 1; + Child = text = new SpriteText + { + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + }; + } + + protected override void OnExpandedChanged(ValueChangedEvent expanded) + { + text.Text = $@"{(expanded.NewValue ? "[+]" : "[-]")} replies ({count})"; + } + } + + private class ParentUsername : FillFlowContainer, IHasTooltip + { + public string TooltipText => getParentMessage(); + + private readonly Comment parentComment; + + public ParentUsername(Comment comment) + { + parentComment = comment.ParentComment; + + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Horizontal; + Spacing = new Vector2(3, 0); + Alpha = comment.ParentId == null ? 0 : 1; + Children = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.Solid.Reply, + Size = new Vector2(14), + }, + new SpriteText + { + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + Text = parentComment?.User?.Username ?? parentComment?.LegacyName + } + }; + } + + private string getParentMessage() + { + if (parentComment == null) + return string.Empty; + + return parentComment.HasMessage ? parentComment.GetMessage : parentComment.IsDeleted ? @"deleted" : string.Empty; + } + } + + private class VotePill : CircularContainer + { + public VotePill(int count) + { + AutoSizeAxes = Axes.X; + Height = 20; + Masking = true; + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(0.05f) + }, + new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = margin }, + Font = OsuFont.GetFont(size: 14), + Text = $"+{count}" + } + }; + } + } + } +} diff --git a/osu.Game/Overlays/Comments/ShowChildrenButton.cs b/osu.Game/Overlays/Comments/ShowChildrenButton.cs new file mode 100644 index 0000000000..be04b6e5de --- /dev/null +++ b/osu.Game/Overlays/Comments/ShowChildrenButton.cs @@ -0,0 +1,34 @@ +// 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.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Framework.Bindables; + +namespace osu.Game.Overlays.Comments +{ + public abstract class ShowChildrenButton : OsuHoverContainer + { + public readonly BindableBool Expanded = new BindableBool(true); + + protected ShowChildrenButton() + { + AutoSizeAxes = Axes.Both; + } + + protected override void LoadComplete() + { + Expanded.BindValueChanged(OnExpandedChanged, true); + base.LoadComplete(); + } + + protected abstract void OnExpandedChanged(ValueChangedEvent expanded); + + protected override bool OnClick(ClickEvent e) + { + Expanded.Value = !Expanded.Value; + return true; + } + } +} diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs b/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs index bb221bd43a..dc1a847b14 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs @@ -19,7 +19,7 @@ namespace osu.Game.Overlays.Profile.Sections { public abstract class PaginatedContainer : FillFlowContainer { - private readonly ShowMoreButton moreButton; + private readonly ProfileShowMoreButton moreButton; private readonly OsuSpriteText missingText; private APIRequest> retrievalRequest; private CancellationTokenSource loadCancellation; @@ -56,7 +56,7 @@ namespace osu.Game.Overlays.Profile.Sections RelativeSizeAxes = Axes.X, Spacing = new Vector2(0, 2), }, - moreButton = new ShowMoreButton + moreButton = new ProfileShowMoreButton { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, diff --git a/osu.Game/Overlays/Profile/Sections/ProfileShowMoreButton.cs b/osu.Game/Overlays/Profile/Sections/ProfileShowMoreButton.cs new file mode 100644 index 0000000000..28486cc743 --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/ProfileShowMoreButton.cs @@ -0,0 +1,20 @@ +// 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.Game.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Profile.Sections +{ + public class ProfileShowMoreButton : ShowMoreButton + { + [BackgroundDependencyLoader] + private void load(OsuColour colors) + { + IdleColour = colors.GreySeafoamDark; + HoverColour = colors.GreySeafoam; + ChevronIconColour = colors.Yellow; + } + } +} diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index a267d7c44d..51155ce3fd 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -4,12 +4,14 @@ using System; 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.Containers; using osu.Framework.Input; using osu.Framework.Logging; +using osu.Framework.Threading; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Configuration; @@ -22,6 +24,7 @@ using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Compose.Components; +using osuTK; namespace osu.Game.Rulesets.Edit { @@ -30,16 +33,20 @@ namespace osu.Game.Rulesets.Edit where TObject : HitObject { protected IRulesetConfigManager Config { get; private set; } - + protected EditorBeatmap EditorBeatmap { get; private set; } protected readonly Ruleset Ruleset; + [Resolved] + protected IFrameBasedClock EditorClock { get; private set; } + private IWorkingBeatmap workingBeatmap; private Beatmap playableBeatmap; - private EditorBeatmap editorBeatmap; private IBeatmapProcessor beatmapProcessor; private DrawableEditRulesetWrapper drawableRulesetWrapper; private BlueprintContainer blueprintContainer; + private Container distanceSnapGridContainer; + private DistanceSnapGrid distanceSnapGrid; private readonly List layerContainers = new List(); private InputManager inputManager; @@ -57,7 +64,8 @@ namespace osu.Game.Rulesets.Edit { drawableRulesetWrapper = new DrawableEditRulesetWrapper(CreateDrawableRuleset(Ruleset, workingBeatmap, Array.Empty())) { - Clock = framedClock + Clock = framedClock, + ProcessCustomClock = false }; } catch (Exception e) @@ -66,11 +74,13 @@ namespace osu.Game.Rulesets.Edit return; } - var layerBelowRuleset = drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer(); - layerBelowRuleset.Child = new EditorPlayfieldBorder { RelativeSizeAxes = Axes.Both }; + var layerBelowRuleset = drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer().WithChildren(new Drawable[] + { + distanceSnapGridContainer = new Container { RelativeSizeAxes = Axes.Both }, + new EditorPlayfieldBorder { RelativeSizeAxes = Axes.Both } + }); - var layerAboveRuleset = drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer(); - layerAboveRuleset.Child = blueprintContainer = new BlueprintContainer(); + var layerAboveRuleset = drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer().WithChild(blueprintContainer = new BlueprintContainer()); layerContainers.Add(layerBelowRuleset); layerContainers.Add(layerAboveRuleset); @@ -113,11 +123,13 @@ namespace osu.Game.Rulesets.Edit }; toolboxCollection.Items = - CompositionTools.Select(t => new RadioButton(t.Name, () => blueprintContainer.CurrentTool = t)) - .Prepend(new RadioButton("Select", () => blueprintContainer.CurrentTool = null)) + CompositionTools.Select(t => new RadioButton(t.Name, () => selectTool(t))) + .Prepend(new RadioButton("Select", () => selectTool(null))) .ToList(); toolboxCollection.Items[0].Select(); + + blueprintContainer.SelectionChanged += selectionChanged; } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -129,14 +141,14 @@ namespace osu.Game.Rulesets.Edit beatmapProcessor = Ruleset.CreateBeatmapProcessor(playableBeatmap); - editorBeatmap = new EditorBeatmap(playableBeatmap); - editorBeatmap.HitObjectAdded += addHitObject; - editorBeatmap.HitObjectRemoved += removeHitObject; - editorBeatmap.StartTimeChanged += updateHitObject; + EditorBeatmap = new EditorBeatmap(playableBeatmap); + EditorBeatmap.HitObjectAdded += addHitObject; + EditorBeatmap.HitObjectRemoved += removeHitObject; + EditorBeatmap.StartTimeChanged += updateHitObject; var dependencies = new DependencyContainer(parent); - dependencies.CacheAs(editorBeatmap); - dependencies.CacheAs>(editorBeatmap); + dependencies.CacheAs(EditorBeatmap); + dependencies.CacheAs>(EditorBeatmap); Config = dependencies.Get().GetConfigFor(Ruleset); @@ -150,6 +162,14 @@ namespace osu.Game.Rulesets.Edit inputManager = GetContainingInputManager(); } + protected override void Update() + { + base.Update(); + + if (EditorClock.ElapsedFrameTime != 0 && blueprintContainer.CurrentTool != null) + showGridFor(Enumerable.Empty()); + } + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); @@ -163,19 +183,53 @@ namespace osu.Game.Rulesets.Edit }); } - private void addHitObject(HitObject hitObject) => updateHitObject(hitObject); - - private void removeHitObject(HitObject hitObject) + private void selectionChanged(IEnumerable selectedHitObjects) { - beatmapProcessor?.PreProcess(); - beatmapProcessor?.PostProcess(); + var hitObjects = selectedHitObjects.ToArray(); + + if (!hitObjects.Any()) + distanceSnapGridContainer.Hide(); + else + showGridFor(hitObjects); } - private void updateHitObject(HitObject hitObject) + private void selectTool(HitObjectCompositionTool tool) { - beatmapProcessor?.PreProcess(); - hitObject.ApplyDefaults(playableBeatmap.ControlPointInfo, playableBeatmap.BeatmapInfo.BaseDifficulty); - beatmapProcessor?.PostProcess(); + blueprintContainer.CurrentTool = tool; + + if (tool == null) + distanceSnapGridContainer.Hide(); + else + showGridFor(Enumerable.Empty()); + } + + private void showGridFor(IEnumerable selectedHitObjects) + { + distanceSnapGridContainer.Clear(); + distanceSnapGrid = CreateDistanceSnapGrid(selectedHitObjects); + + if (distanceSnapGrid != null) + { + distanceSnapGridContainer.Child = distanceSnapGrid; + distanceSnapGridContainer.Show(); + } + } + + private ScheduledDelegate scheduledUpdate; + + private void addHitObject(HitObject hitObject) => updateHitObject(hitObject); + + private void removeHitObject(HitObject hitObject) => updateHitObject(null); + + private void updateHitObject([CanBeNull] HitObject hitObject) + { + scheduledUpdate?.Cancel(); + scheduledUpdate = Schedule(() => + { + beatmapProcessor?.PreProcess(); + hitObject?.ApplyDefaults(playableBeatmap.ControlPointInfo, playableBeatmap.BeatmapInfo.BaseDifficulty); + beatmapProcessor?.PostProcess(); + }); } public override IEnumerable HitObjects => drawableRulesetWrapper.Playfield.AllHitObjects; @@ -187,20 +241,30 @@ namespace osu.Game.Rulesets.Edit public void BeginPlacement(HitObject hitObject) { + if (distanceSnapGrid != null) + hitObject.StartTime = GetSnappedTime(hitObject.StartTime, distanceSnapGrid.ToLocalSpace(inputManager.CurrentState.Mouse.Position)); } - public void EndPlacement(HitObject hitObject) => editorBeatmap.Add(hitObject); + public void EndPlacement(HitObject hitObject) + { + EditorBeatmap.Add(hitObject); + showGridFor(Enumerable.Empty()); + } - public void Delete(HitObject hitObject) => editorBeatmap.Remove(hitObject); + public void Delete(HitObject hitObject) => EditorBeatmap.Remove(hitObject); + + public override Vector2 GetSnappedPosition(Vector2 position) => distanceSnapGrid?.GetSnapPosition(position) ?? position; + + public override double GetSnappedTime(double startTime, Vector2 position) => distanceSnapGrid?.GetSnapTime(position) ?? startTime; protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (editorBeatmap != null) + if (EditorBeatmap != null) { - editorBeatmap.HitObjectAdded -= addHitObject; - editorBeatmap.HitObjectRemoved -= removeHitObject; + EditorBeatmap.HitObjectAdded -= addHitObject; + EditorBeatmap.HitObjectRemoved -= removeHitObject; } } } @@ -233,5 +297,17 @@ namespace osu.Game.Rulesets.Edit /// Creates a which outlines s and handles movement of selections. /// public virtual SelectionHandler CreateSelectionHandler() => new SelectionHandler(); + + /// + /// Creates the applicable for a selection. + /// + /// The selection. + /// The for . + [CanBeNull] + protected virtual DistanceSnapGrid CreateDistanceSnapGrid([NotNull] IEnumerable selectedHitObjects) => null; + + public abstract Vector2 GetSnappedPosition(Vector2 position); + + public abstract double GetSnappedTime(double startTime, Vector2 screenSpacePosition); } } diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index 290fd8d27d..07283d2245 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -91,8 +91,10 @@ namespace osu.Game.Rulesets.Edit /// /// Signals that the placement of has started. /// - protected void BeginPlacement() + /// The start time of at the placement point. If null, the current clock time is used. + protected void BeginPlacement(double? startTime = null) { + HitObject.StartTime = startTime ?? EditorClock.CurrentTime; placementHandler.BeginPlacement(HitObject); PlacementBegun = true; } diff --git a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs index 838984b223..3076ad081a 100644 --- a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs +++ b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs @@ -43,20 +43,20 @@ namespace osu.Game.Rulesets.Edit /// /// The which this applies to. /// - public readonly DrawableHitObject HitObject; + public readonly DrawableHitObject DrawableObject; /// - /// The screen-space position of prior to handling a movement event. + /// The screen-space position of prior to handling a movement event. /// internal Vector2 ScreenSpaceMovementStartPosition { get; private set; } - protected override bool ShouldBeAlive => (HitObject.IsAlive && HitObject.IsPresent) || State == SelectionState.Selected; + protected override bool ShouldBeAlive => (DrawableObject.IsAlive && DrawableObject.IsPresent) || State == SelectionState.Selected; public override bool HandlePositionalInput => ShouldBeAlive; public override bool RemoveWhenNotAlive => false; - protected SelectionBlueprint(DrawableHitObject hitObject) + protected SelectionBlueprint(DrawableHitObject drawableObject) { - HitObject = hitObject; + DrawableObject = drawableObject; RelativeSizeAxes = Axes.Both; @@ -107,7 +107,7 @@ namespace osu.Game.Rulesets.Edit public bool IsSelected => State == SelectionState.Selected; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => HitObject.ReceivePositionalInputAt(screenSpacePos); + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.ReceivePositionalInputAt(screenSpacePos); private bool selectionRequested; @@ -138,7 +138,7 @@ namespace osu.Game.Rulesets.Edit protected override bool OnDragStart(DragStartEvent e) { - ScreenSpaceMovementStartPosition = HitObject.ToScreenSpace(HitObject.OriginPosition); + ScreenSpaceMovementStartPosition = DrawableObject.ToScreenSpace(DrawableObject.OriginPosition); return true; } @@ -151,11 +151,11 @@ namespace osu.Game.Rulesets.Edit /// /// The screen-space point that causes this to be selected. /// - public virtual Vector2 SelectionPoint => HitObject.ScreenSpaceDrawQuad.Centre; + public virtual Vector2 SelectionPoint => DrawableObject.ScreenSpaceDrawQuad.Centre; /// /// The screen-space quad that outlines this for selections. /// - public virtual Quad SelectionQuad => HitObject.ScreenSpaceDrawQuad; + public virtual Quad SelectionQuad => DrawableObject.ScreenSpaceDrawQuad; } } diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 7f3bfd3b5c..1d9b66f88d 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; @@ -37,7 +39,7 @@ namespace osu.Game.Rulesets.Objects.Drawables protected virtual IEnumerable GetSamples() => HitObject.Samples; private readonly Lazy> nestedHitObjects = new Lazy>(); - public IEnumerable NestedHitObjects => nestedHitObjects.IsValueCreated ? nestedHitObjects.Value : Enumerable.Empty(); + public IReadOnlyList NestedHitObjects => nestedHitObjects.IsValueCreated ? nestedHitObjects.Value : (IReadOnlyList)Array.Empty(); /// /// Invoked when a has been applied by this or a nested . @@ -76,6 +78,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// public JudgementResult Result { get; private set; } + private Bindable startTimeBindable; private Bindable comboIndexBindable; public override bool RemoveWhenNotAlive => false; @@ -88,9 +91,9 @@ namespace osu.Game.Rulesets.Objects.Drawables public IBindable State => state; - protected DrawableHitObject(HitObject hitObject) + protected DrawableHitObject([NotNull] HitObject hitObject) { - HitObject = hitObject; + HitObject = hitObject ?? throw new ArgumentNullException(nameof(hitObject)); } [BackgroundDependencyLoader] @@ -125,13 +128,83 @@ namespace osu.Game.Rulesets.Objects.Drawables { base.LoadComplete(); + HitObject.DefaultsApplied += onDefaultsApplied; + + startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy(); + startTimeBindable.BindValueChanged(_ => updateState(ArmedState.Idle, true)); + if (HitObject is IHasComboInformation combo) { comboIndexBindable = combo.ComboIndexBindable.GetBoundCopy(); - comboIndexBindable.BindValueChanged(_ => updateAccentColour()); + comboIndexBindable.BindValueChanged(_ => updateAccentColour(), true); } updateState(ArmedState.Idle, true); + onDefaultsApplied(); + } + + private void onDefaultsApplied() => apply(HitObject); + + private void apply(HitObject hitObject) + { +#pragma warning disable 618 // can be removed 20200417 + if (GetType().GetMethod(nameof(AddNested), BindingFlags.NonPublic | BindingFlags.Instance)?.DeclaringType != typeof(DrawableHitObject)) + return; +#pragma warning restore 618 + + if (nestedHitObjects.IsValueCreated) + { + nestedHitObjects.Value.Clear(); + ClearNestedHitObjects(); + } + + foreach (var h in hitObject.NestedHitObjects) + { + var drawableNested = CreateNestedHitObject(h) ?? throw new InvalidOperationException($"{nameof(CreateNestedHitObject)} returned null for {h.GetType().ReadableName()}."); + + addNested(drawableNested); + AddNestedHitObject(drawableNested); + } + } + + /// + /// Invoked by the base to add nested s to the hierarchy. + /// + /// The to be added. + protected virtual void AddNestedHitObject(DrawableHitObject hitObject) + { + } + + /// + /// Adds a nested . This should not be used except for legacy nested usages. + /// + /// + [Obsolete("Use AddNestedHitObject() / ClearNestedHitObjects() / CreateNestedHitObject() instead.")] // can be removed 20200417 + protected virtual void AddNested(DrawableHitObject h) => addNested(h); + + /// + /// Invoked by the base to remove all previously-added nested s. + /// + protected virtual void ClearNestedHitObjects() + { + } + + /// + /// Creates the drawable representation for a nested . + /// + /// The . + /// The drawable representation for . + protected virtual DrawableHitObject CreateNestedHitObject(HitObject hitObject) => null; + + private void addNested(DrawableHitObject hitObject) + { + // Todo: Exists for legacy purposes, can be removed 20200417 + + hitObject.OnNewResult += (d, r) => OnNewResult?.Invoke(d, r); + hitObject.OnRevertResult += (d, r) => OnRevertResult?.Invoke(d, r); + hitObject.ApplyCustomUpdateState += (d, j) => ApplyCustomUpdateState?.Invoke(d, j); + + nestedHitObjects.Value.Add(hitObject); } #region State / Transform Management @@ -356,15 +429,6 @@ namespace osu.Game.Rulesets.Objects.Drawables UpdateResult(false); } - protected virtual void AddNested(DrawableHitObject h) - { - h.OnNewResult += (d, r) => OnNewResult?.Invoke(d, r); - h.OnRevertResult += (d, r) => OnRevertResult?.Invoke(d, r); - h.ApplyCustomUpdateState += (d, j) => ApplyCustomUpdateState?.Invoke(d, j); - - nestedHitObjects.Value.Add(h); - } - /// /// Applies the of this , notifying responders such as /// the of the . @@ -437,6 +501,12 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// The that provides the scoring information. protected virtual JudgementResult CreateResult(Judgement judgement) => new JudgementResult(HitObject, judgement); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + HitObject.DefaultsApplied -= onDefaultsApplied; + } } public abstract class DrawableHitObject : DrawableHitObject diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index eb8652443f..6211fe50e6 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -1,6 +1,7 @@ // 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; @@ -28,6 +29,11 @@ namespace osu.Game.Rulesets.Objects /// private const double control_point_leniency = 1; + /// + /// Invoked after has completed on this . + /// + public event Action DefaultsApplied; + public readonly Bindable StartTimeBindable = new Bindable(); /// @@ -113,6 +119,8 @@ namespace osu.Game.Rulesets.Objects foreach (var h in nestedHitObjects) h.ApplyDefaults(controlPointInfo, difficulty); + + DefaultsApplied?.Invoke(); } protected virtual void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index 47aad43966..23988ff0ff 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -11,28 +11,22 @@ using osu.Game.Database; namespace osu.Game.Rulesets { - /// - /// Todo: All of this needs to be moved to a RulesetStore. - /// - public class RulesetStore : DatabaseBackedStore + public class RulesetStore : DatabaseBackedStore, IDisposable { - private static readonly Dictionary loaded_assemblies = new Dictionary(); + private const string ruleset_library_prefix = "osu.Game.Rulesets"; - static RulesetStore() - { - AppDomain.CurrentDomain.AssemblyResolve += currentDomain_AssemblyResolve; - - // On android in release configuration assemblies are loaded from the apk directly into memory. - // We cannot read assemblies from cwd, so should check loaded assemblies instead. - loadFromAppDomain(); - - loadFromDisk(); - } + private readonly Dictionary loadedAssemblies = new Dictionary(); public RulesetStore(IDatabaseContextFactory factory) : base(factory) { + // On android in release configuration assemblies are loaded from the apk directly into memory. + // We cannot read assemblies from cwd, so should check loaded assemblies instead. + loadFromAppDomain(); + loadFromDisk(); addMissingRulesets(); + + AppDomain.CurrentDomain.AssemblyResolve += resolveRulesetAssembly; } /// @@ -54,9 +48,7 @@ namespace osu.Game.Rulesets /// public IEnumerable AvailableRulesets { get; private set; } - private static Assembly currentDomain_AssemblyResolve(object sender, ResolveEventArgs args) => loaded_assemblies.Keys.FirstOrDefault(a => a.FullName == args.Name); - - private const string ruleset_library_prefix = "osu.Game.Rulesets"; + private Assembly resolveRulesetAssembly(object sender, ResolveEventArgs args) => loadedAssemblies.Keys.FirstOrDefault(a => a.FullName == args.Name); private void addMissingRulesets() { @@ -64,7 +56,7 @@ namespace osu.Game.Rulesets { var context = usage.Context; - var instances = loaded_assemblies.Values.Select(r => (Ruleset)Activator.CreateInstance(r, (RulesetInfo)null)).ToList(); + var instances = loadedAssemblies.Values.Select(r => (Ruleset)Activator.CreateInstance(r, (RulesetInfo)null)).ToList(); //add all legacy modes in correct order foreach (var r in instances.Where(r => r.LegacyID != null).OrderBy(r => r.LegacyID)) @@ -113,7 +105,7 @@ namespace osu.Game.Rulesets } } - private static void loadFromAppDomain() + private void loadFromAppDomain() { foreach (var ruleset in AppDomain.CurrentDomain.GetAssemblies()) { @@ -126,7 +118,7 @@ namespace osu.Game.Rulesets } } - private static void loadFromDisk() + private void loadFromDisk() { try { @@ -141,11 +133,11 @@ namespace osu.Game.Rulesets } } - private static void loadRulesetFromFile(string file) + private void loadRulesetFromFile(string file) { var filename = Path.GetFileNameWithoutExtension(file); - if (loaded_assemblies.Values.Any(t => t.Namespace == filename)) + if (loadedAssemblies.Values.Any(t => t.Namespace == filename)) return; try @@ -158,19 +150,30 @@ namespace osu.Game.Rulesets } } - private static void addRuleset(Assembly assembly) + private void addRuleset(Assembly assembly) { - if (loaded_assemblies.ContainsKey(assembly)) + if (loadedAssemblies.ContainsKey(assembly)) return; try { - loaded_assemblies[assembly] = assembly.GetTypes().First(t => t.IsPublic && t.IsSubclassOf(typeof(Ruleset))); + loadedAssemblies[assembly] = assembly.GetTypes().First(t => t.IsPublic && t.IsSubclassOf(typeof(Ruleset))); } catch (Exception e) { Logger.Error(e, $"Failed to add ruleset {assembly}"); } } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetAssembly; + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 2de5ecf633..4001a0f33a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -1,6 +1,7 @@ // 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; @@ -14,20 +15,20 @@ using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osuTK; namespace osu.Game.Screens.Edit.Compose.Components { public class BlueprintContainer : CompositeDrawable { - private SelectionBlueprintContainer selectionBlueprints; + public event Action> SelectionChanged; + private SelectionBlueprintContainer selectionBlueprints; private Container placementBlueprintContainer; private PlacementBlueprint currentPlacement; private SelectionHandler selectionHandler; private InputManager inputManager; - private IEnumerable selections => selectionBlueprints.Children.Where(c => c.IsAlive); - [Resolved] private HitObjectComposer composer { get; set; } @@ -101,7 +102,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void removeBlueprintFor(HitObject hitObject) { - var blueprint = selectionBlueprints.Single(m => m.HitObject.HitObject == hitObject); + var blueprint = selectionBlueprints.Single(m => m.DrawableObject.HitObject == hitObject); if (blueprint == null) return; @@ -143,7 +144,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { if (currentPlacement != null) { - currentPlacement.UpdatePosition(e.ScreenSpaceMousePosition); + updatePlacementPosition(e.ScreenSpaceMousePosition); return true; } @@ -178,19 +179,27 @@ namespace osu.Game.Screens.Edit.Compose.Components placementBlueprintContainer.Child = currentPlacement = blueprint; // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame - blueprint.UpdatePosition(inputManager.CurrentState.Mouse.Position); + updatePlacementPosition(inputManager.CurrentState.Mouse.Position); } } + private void updatePlacementPosition(Vector2 screenSpacePosition) + { + Vector2 snappedGridPosition = composer.GetSnappedPosition(ToLocalSpace(screenSpacePosition)); + Vector2 snappedScreenSpacePosition = ToScreenSpace(snappedGridPosition); + + currentPlacement.UpdatePosition(snappedScreenSpacePosition); + } + /// /// Select all masks in a given rectangle selection area. /// /// The rectangle to perform a selection on in screen-space coordinates. private void select(RectangleF rect) { - foreach (var blueprint in selections.ToList()) + foreach (var blueprint in selectionBlueprints) { - if (blueprint.IsPresent && rect.Contains(blueprint.SelectionPoint)) + if (blueprint.IsAlive && blueprint.IsPresent && rect.Contains(blueprint.SelectionPoint)) blueprint.Select(); else blueprint.Deselect(); @@ -200,27 +209,40 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Deselects all selected s. /// - private void deselectAll() => selections.ToList().ForEach(m => m.Deselect()); + private void deselectAll() => selectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect()); private void onBlueprintSelected(SelectionBlueprint blueprint) { selectionHandler.HandleSelected(blueprint); selectionBlueprints.ChangeChildDepth(blueprint, 1); + + SelectionChanged?.Invoke(selectionHandler.SelectedHitObjects); } private void onBlueprintDeselected(SelectionBlueprint blueprint) { selectionHandler.HandleDeselected(blueprint); selectionBlueprints.ChangeChildDepth(blueprint, 0); + + SelectionChanged?.Invoke(selectionHandler.SelectedHitObjects); } private void onSelectionRequested(SelectionBlueprint blueprint, InputState state) => selectionHandler.HandleSelectionRequested(blueprint, state); private void onDragRequested(SelectionBlueprint blueprint, DragEvent dragEvent) { - var movePosition = blueprint.ScreenSpaceMovementStartPosition + dragEvent.ScreenSpaceMousePosition - dragEvent.ScreenSpaceMouseDownPosition; + HitObject draggedObject = blueprint.DrawableObject.HitObject; - selectionHandler.HandleMovement(new MoveSelectionEvent(blueprint, blueprint.ScreenSpaceMovementStartPosition, movePosition)); + Vector2 movePosition = blueprint.ScreenSpaceMovementStartPosition + dragEvent.ScreenSpaceMousePosition - dragEvent.ScreenSpaceMouseDownPosition; + Vector2 snappedPosition = composer.GetSnappedPosition(ToLocalSpace(movePosition)); + + // Move the hitobjects + selectionHandler.HandleMovement(new MoveSelectionEvent(blueprint, blueprint.ScreenSpaceMovementStartPosition, ToScreenSpace(snappedPosition))); + + // Apply the start time at the newly snapped-to position + double offset = composer.GetSnappedTime(draggedObject.StartTime, snappedPosition) - draggedObject.StartTime; + foreach (HitObject obj in selectionHandler.SelectedHitObjects) + obj.StartTime += offset; } protected override void Dispose(bool isDisposing) @@ -252,7 +274,7 @@ namespace osu.Game.Screens.Edit.Compose.Components return d; // Put earlier hitobjects towards the end of the list, so they handle input first - int i = y.HitObject.HitObject.StartTime.CompareTo(x.HitObject.HitObject.StartTime); + int i = y.DrawableObject.HitObject.StartTime.CompareTo(x.DrawableObject.HitObject.StartTime); return i == 0 ? CompareReverseChildID(x, y) : i; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs new file mode 100644 index 0000000000..3cbf926d4f --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs @@ -0,0 +1,59 @@ +// 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.UserInterface; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public abstract class CircularDistanceSnapGrid : DistanceSnapGrid + { + protected CircularDistanceSnapGrid(HitObject hitObject, Vector2 centrePosition) + : base(hitObject, centrePosition) + { + } + + protected override void CreateContent(Vector2 centrePosition) + { + float dx = Math.Max(centrePosition.X, DrawWidth - centrePosition.X); + float dy = Math.Max(centrePosition.Y, DrawHeight - centrePosition.Y); + float maxDistance = new Vector2(dx, dy).Length; + + int requiredCircles = (int)(maxDistance / DistanceSpacing); + + for (int i = 0; i < requiredCircles; i++) + { + float radius = (i + 1) * DistanceSpacing * 2; + + AddInternal(new CircularProgress + { + Origin = Anchor.Centre, + Position = centrePosition, + Current = { Value = 1 }, + Size = new Vector2(radius), + InnerRadius = 4 * 1f / radius, + Colour = GetColourForBeatIndex(i) + }); + } + } + + public override Vector2 GetSnapPosition(Vector2 position) + { + Vector2 direction = position - CentrePosition; + + if (direction == Vector2.Zero) + direction = new Vector2(0.001f, 0.001f); + + float distance = direction.Length; + + float radius = DistanceSpacing; + int radialCount = Math.Max(1, (int)Math.Round(distance / radius)); + + Vector2 normalisedDirection = direction * new Vector2(1f / distance); + return CentrePosition + normalisedDirection * radialCount * radius; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs similarity index 92% rename from osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs rename to osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs index 9040843144..299e78b7c0 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs @@ -15,7 +15,10 @@ using osuTK; namespace osu.Game.Screens.Edit.Compose.Components { - public abstract class BeatSnapGrid : CompositeDrawable + /// + /// A grid which takes user input and returns a quantized ("snapped") position and time. + /// + public abstract class DistanceSnapGrid : CompositeDrawable { /// /// The velocity of the beatmap at the point of placement in pixels per millisecond. @@ -48,7 +51,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private double startTime; private double beatLength; - protected BeatSnapGrid(HitObject hitObject, Vector2 centrePosition) + protected DistanceSnapGrid(HitObject hitObject, Vector2 centrePosition) { this.hitObject = hitObject; this.CentrePosition = centrePosition; @@ -114,14 +117,14 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Snaps a position to this grid. /// - /// The original position in coordinate space local to this . - /// The snapped position in coordinate space local to this . + /// 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 snapped position in coordinate space local to this . /// The time at the snapped position. public double GetSnapTime(Vector2 position) => startTime + (position - CentrePosition).Length / Velocity; diff --git a/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs index 13945381bb..fe0a47aec8 100644 --- a/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs +++ b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs @@ -40,7 +40,7 @@ namespace osu.Game.Screens.Edit.Compose.Components ScreenSpaceStartPosition = screenSpaceStartPosition; ScreenSpacePosition = screenSpacePosition; - InstantDelta = Blueprint.HitObject.Parent.ToLocalSpace(ScreenSpacePosition) - Blueprint.HitObject.Position; + InstantDelta = Blueprint.DrawableObject.Parent.ToLocalSpace(ScreenSpacePosition) - Blueprint.DrawableObject.Position; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index c9e862d99e..d7821eff07 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -26,10 +26,10 @@ namespace osu.Game.Screens.Edit.Compose.Components { public const float BORDER_RADIUS = 2; - protected IEnumerable SelectedBlueprints => selectedBlueprints; + public IEnumerable SelectedBlueprints => selectedBlueprints; private readonly List selectedBlueprints; - protected IEnumerable SelectedHitObjects => selectedBlueprints.Select(b => b.HitObject.HitObject); + public IEnumerable SelectedHitObjects => selectedBlueprints.Select(b => b.DrawableObject.HitObject); private Drawable outline; @@ -81,7 +81,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { case Key.Delete: foreach (var h in selectedBlueprints.ToList()) - placementHandler.Delete(h.HitObject.HitObject); + placementHandler.Delete(h.DrawableObject.HitObject); return true; } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 7f08c2f8b9..35408e4003 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -173,6 +173,12 @@ namespace osu.Game.Screens.Edit bottomBackground.Colour = colours.Gray2; } + protected override void Update() + { + base.Update(); + clock.ProcessFrame(); + } + protected override bool OnKeyDown(KeyDownEvent e) { switch (e.Key) diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index ab7c40116b..64172a0954 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -26,7 +26,7 @@ - + diff --git a/osu.iOS.props b/osu.iOS.props index 925a217a13..5d384888d2 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -118,8 +118,8 @@ - - + +