diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index cbee1694ba..dc712f2593 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -32,7 +32,7 @@ namespace osu.Desktop var split = arg.Split('='); var key = split[0]; - var val = split[1]; + var val = split.Length > 1 ? split[1] : string.Empty; switch (key) { diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs new file mode 100644 index 0000000000..cd1fa31b61 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs @@ -0,0 +1,155 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public class TestSceneJuiceStreamPlacementBlueprint : CatchPlacementBlueprintTestScene + { + private const double velocity = 0.5; + + private JuiceStream lastObject => LastObject?.HitObject as JuiceStream; + + [BackgroundDependencyLoader] + private void load() + { + Beatmap.Value.BeatmapInfo.BaseDifficulty.SliderTickRate = 5; + Beatmap.Value.BeatmapInfo.BaseDifficulty.SliderMultiplier = velocity * 10; + } + + [Test] + public void TestBasicPlacement() + { + double[] times = { 300, 800 }; + float[] positions = { 100, 200 }; + addPlacementSteps(times, positions); + + AddAssert("juice stream is placed", () => lastObject != null); + AddAssert("start time is correct", () => Precision.AlmostEquals(lastObject.StartTime, times[0])); + AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1])); + AddAssert("start position is correct", () => Precision.AlmostEquals(lastObject.OriginalX, positions[0])); + AddAssert("end position is correct", () => Precision.AlmostEquals(lastObject.EndX, positions[1])); + } + + [Test] + public void TestEmptyNotCommitted() + { + addMoveAndClickSteps(100, 100); + addMoveAndClickSteps(100, 100); + addMoveAndClickSteps(100, 100, true); + AddAssert("juice stream not placed", () => lastObject == null); + } + + [Test] + public void TestMultipleSegments() + { + double[] times = { 100, 300, 500, 700 }; + float[] positions = { 100, 150, 100, 100 }; + addPlacementSteps(times, positions); + + AddAssert("has 4 vertices", () => lastObject.Path.ControlPoints.Count == 4); + addPathCheckStep(times, positions); + } + + [Test] + public void TestVelocityLimit() + { + double[] times = { 100, 300 }; + float[] positions = { 200, 500 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, new float[] { 200, 300 }); + } + + [Test] + public void TestPreviousVerticesAreFixed() + { + double[] times = { 100, 300, 500, 700 }; + float[] positions = { 200, 400, 100, 500 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, new float[] { 200, 300, 200, 300 }); + } + + [Test] + public void TestClampedPositionIsRestored() + { + double[] times = { 100, 300, 500 }; + float[] positions = { 200, 200, 0, 250 }; + + addMoveAndClickSteps(times[0], positions[0]); + addMoveAndClickSteps(times[1], positions[1]); + AddMoveStep(times[2], positions[2]); + addMoveAndClickSteps(times[2], positions[3], true); + + addPathCheckStep(times, new float[] { 200, 200, 250 }); + } + + [Test] + public void TestFirstVertexIsFixed() + { + double[] times = { 100, 200 }; + float[] positions = { 100, 300 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, new float[] { 100, 150 }); + } + + [Test] + public void TestOutOfOrder() + { + double[] times = { 100, 700, 500, 300 }; + float[] positions = { 100, 200, 150, 50 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, positions); + } + + [Test] + public void TestMoveBeforeFirstVertex() + { + double[] times = { 300, 500, 100 }; + float[] positions = { 100, 100, 100 }; + addPlacementSteps(times, positions); + AddAssert("start time is correct", () => Precision.AlmostEquals(lastObject.StartTime, times[0])); + AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1], 1e-3)); + } + + protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableJuiceStream((JuiceStream)hitObject); + + protected override PlacementBlueprint CreateBlueprint() => new JuiceStreamPlacementBlueprint(); + + private void addMoveAndClickSteps(double time, float position, bool end = false) + { + AddMoveStep(time, position); + AddClickStep(end ? MouseButton.Right : MouseButton.Left); + } + + private void addPlacementSteps(double[] times, float[] positions) + { + for (int i = 0; i < times.Length; i++) + addMoveAndClickSteps(times[i], positions[i], i == times.Length - 1); + } + + private void addPathCheckStep(double[] times, float[] positions) => AddStep("assert path is correct", () => + Assert.That(getPositions(times), Is.EqualTo(positions).Within(Precision.FLOAT_EPSILON))); + + private float[] getPositions(IEnumerable times) + { + JuiceStream hitObject = lastObject.AsNonNull(); + return times + .Select(time => (time - hitObject.StartTime) * hitObject.Velocity) + .Select(distance => hitObject.EffectiveX + hitObject.Path.PositionAt(distance / hitObject.Distance).X) + .ToArray(); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs new file mode 100644 index 0000000000..158872fbab --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs @@ -0,0 +1,49 @@ +// 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.Game.Rulesets.Catch.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class PlacementEditablePath : EditablePath + { + /// + /// The original position of the last added vertex. + /// This is not same as the last vertex of the current path because the vertex ordering can change. + /// + private JuiceStreamPathVertex lastVertex; + + public PlacementEditablePath(Func positionToDistance) + : base(positionToDistance) + { + } + + public void AddNewVertex() + { + var endVertex = Vertices[^1]; + int index = AddVertex(endVertex.Distance, endVertex.X); + + for (int i = 0; i < VertexCount; i++) + { + VertexStates[i].IsSelected = i == index; + VertexStates[i].IsFixed = i != index; + VertexStates[i].VertexBeforeChange = Vertices[i]; + } + + lastVertex = Vertices[index]; + } + + /// + /// Move the vertex added by in the last time. + /// + public void MoveLastVertex(Vector2 screenSpacePosition) + { + Vector2 position = ToRelativePosition(screenSpacePosition); + double distanceDelta = PositionToDistance(position.Y) - lastVertex.Distance; + float xDelta = position.X - lastVertex.X; + MoveSelectedVertices(distanceDelta, xDelta); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs new file mode 100644 index 0000000000..cff5bc2417 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs @@ -0,0 +1,128 @@ +// 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.Framework.Input; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class JuiceStreamPlacementBlueprint : CatchPlacementBlueprint + { + private readonly ScrollingPath scrollingPath; + + private readonly NestedOutlineContainer nestedOutlineContainer; + + private readonly PlacementEditablePath editablePath; + + private int lastEditablePathId = -1; + + private InputManager inputManager; + + public JuiceStreamPlacementBlueprint() + { + InternalChildren = new Drawable[] + { + scrollingPath = new ScrollingPath(), + nestedOutlineContainer = new NestedOutlineContainer(), + editablePath = new PlacementEditablePath(positionToDistance) + }; + } + + protected override void Update() + { + base.Update(); + + if (PlacementActive == PlacementState.Active) + editablePath.UpdateFrom(HitObjectContainer, HitObject); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + switch (PlacementActive) + { + case PlacementState.Waiting: + if (e.Button != MouseButton.Left) break; + + editablePath.AddNewVertex(); + BeginPlacement(true); + return true; + + case PlacementState.Active: + switch (e.Button) + { + case MouseButton.Left: + editablePath.AddNewVertex(); + return true; + + case MouseButton.Right: + EndPlacement(HitObject.Duration > 0); + return true; + } + + break; + } + + return base.OnMouseDown(e); + } + + public override void UpdateTimeAndPosition(SnapResult result) + { + switch (PlacementActive) + { + case PlacementState.Waiting: + if (!(result.Time is double snappedTime)) return; + + HitObject.OriginalX = ToLocalSpace(result.ScreenSpacePosition).X; + HitObject.StartTime = snappedTime; + break; + + case PlacementState.Active: + Vector2 unsnappedPosition = inputManager.CurrentState.Mouse.Position; + editablePath.MoveLastVertex(unsnappedPosition); + break; + + default: + return; + } + + // Make sure the up-to-date position is used for outlines. + Vector2 startPosition = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + editablePath.Position = nestedOutlineContainer.Position = scrollingPath.Position = startPosition; + + updateHitObjectFromPath(); + } + + private void updateHitObjectFromPath() + { + if (lastEditablePathId == editablePath.PathId) + return; + + editablePath.UpdateHitObjectFromPath(HitObject); + ApplyDefaultsToHitObject(); + + scrollingPath.UpdatePathFrom(HitObjectContainer, HitObject); + nestedOutlineContainer.UpdateNestedObjectsFrom(HitObjectContainer, HitObject); + + lastEditablePathId = editablePath.PathId; + } + + private double positionToDistance(float relativeYPosition) + { + double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime); + return (time - HitObject.StartTime) * HitObject.Velocity; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index d360274aa6..050c2f625d 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -38,6 +38,7 @@ namespace osu.Game.Rulesets.Catch.Edit protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { new FruitCompositionTool(), + new JuiceStreamCompositionTool(), new BananaShowerCompositionTool() }; diff --git a/osu.Game.Rulesets.Catch/Edit/JuiceStreamCompositionTool.cs b/osu.Game.Rulesets.Catch/Edit/JuiceStreamCompositionTool.cs new file mode 100644 index 0000000000..cb66e2952e --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/JuiceStreamCompositionTool.cs @@ -0,0 +1,24 @@ +// 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.Beatmaps; +using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class JuiceStreamCompositionTool : HitObjectCompositionTool + { + public JuiceStreamCompositionTool() + : base(nameof(JuiceStream)) + { + } + + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); + + public override PlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint(); + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index a87361e33c..8e32b2e6a7 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -757,7 +757,7 @@ namespace osu.Game loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true); loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); - loadComponentSingleFile(skinEditor = new SkinEditorOverlay(screenContainer), overlayContent.Add); + loadComponentSingleFile(skinEditor = new SkinEditorOverlay(screenContainer), overlayContent.Add, true); loadComponentSingleFile(new LoginOverlay { diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 316837d27d..9f3543d059 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -13,6 +14,7 @@ using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Skinning; +using osu.Game.Skinning.Editor; using osuTK; namespace osu.Game.Overlays.Settings.Sections @@ -57,14 +59,19 @@ namespace osu.Game.Overlays.Settings.Sections private IBindable> managerUpdated; private IBindable> managerRemoved; - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + [BackgroundDependencyLoader(permitNulls: true)] + private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor) { FlowContent.Spacing = new Vector2(0, 5); Children = new Drawable[] { skinDropdown = new SkinSettingsDropdown(), + new SettingsButton + { + Text = "Skin layout editor", + Action = () => skinEditor?.Toggle(), + }, new ExportSkinButton(), new SettingsSlider { diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index 07a94cac7a..8052f82c93 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -14,6 +14,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Skinning.Editor @@ -88,6 +89,13 @@ namespace osu.Game.Skinning.Editor Children = new Drawable[] { new SkinBlueprintContainer(targetScreen), + new TriangleButton + { + Margin = new MarginPadding(10), + Text = CommonStrings.ButtonsClose, + Width = 100, + Action = Hide, + }, new FillFlowContainer { Direction = FillDirection.Horizontal, diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs index 88020896bb..2562e9c57c 100644 --- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -37,29 +37,46 @@ namespace osu.Game.Skinning.Editor switch (action) { case GlobalAction.Back: - if (skinEditor?.State.Value == Visibility.Visible) - { - skinEditor.ToggleVisibility(); - return true; - } + if (skinEditor?.State.Value != Visibility.Visible) + break; - break; + Hide(); + return true; case GlobalAction.ToggleSkinEditor: - if (skinEditor == null) - { - LoadComponentAsync(skinEditor = new SkinEditor(target), AddInternal); - skinEditor.State.BindValueChanged(editorVisibilityChanged); - } - else - skinEditor.ToggleVisibility(); - + Toggle(); return true; } return false; } + public void Toggle() + { + if (skinEditor == null) + Show(); + else + skinEditor.ToggleVisibility(); + } + + public override void Hide() + { + // base call intentionally omitted. + skinEditor.Hide(); + } + + public override void Show() + { + // base call intentionally omitted. + if (skinEditor == null) + { + LoadComponentAsync(skinEditor = new SkinEditor(target), AddInternal); + skinEditor.State.BindValueChanged(editorVisibilityChanged); + } + else + skinEditor.Show(); + } + private void editorVisibilityChanged(ValueChangedEvent visibility) { if (visibility.NewValue == Visibility.Visible)