diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 729f2f266d..1d5c565d60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: uses: actions/cache@v3 with: path: ${{ github.workspace }}/inspectcode - key: inspectcode-${{ hashFiles('.config/dotnet-tools.json', '.github/workflows/ci.yml', 'osu.sln*', '.editorconfig', '.globalconfig') }} + key: inspectcode-${{ hashFiles('.config/dotnet-tools.json', '.github/workflows/ci.yml', 'osu.sln*', '.editorconfig', '.globalconfig', 'CodeAnalysis/*') }} - name: Dotnet code style run: dotnet build -c Debug -warnaserror osu.Desktop.slnf -p:EnforceCodeStyleInBuild=true diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml new file mode 100644 index 0000000000..8ca9f38234 --- /dev/null +++ b/.github/workflows/sentry-release.yml @@ -0,0 +1,26 @@ +name: Add Release to Sentry + +on: + push: + tags: + - '*' + +jobs: + sentry_release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Create Sentry release + uses: getsentry/action-release@v1 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ppy + SENTRY_PROJECT: osu + SENTRY_URL: https://sentry.ppy.sh/ + with: + environment: production + version: ${{ github.ref }} diff --git a/.gitignore b/.gitignore index 5b19270ab9..0c7a18b437 100644 --- a/.gitignore +++ b/.gitignore @@ -340,3 +340,5 @@ inspectcode # Fody (pulled in by Realm) - schema file FodyWeavers.xsd **/FodyWeavers.xml + +.idea/.idea.osu.Desktop/.idea/misc.xml \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/misc.xml b/.idea/.idea.osu.Desktop/.idea/misc.xml deleted file mode 100644 index 4e1d56f4dd..0000000000 --- a/.idea/.idea.osu.Desktop/.idea/misc.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index e96ad48325..b72df0a306 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -11,6 +11,7 @@ T:Microsoft.EntityFrameworkCore.Internal.TypeExtensions;Don't use internal exten T:NuGet.Packaging.CollectionExtensions;Don't use internal extension methods. M:System.Enum.HasFlag(System.Enum);Use osu.Framework.Extensions.EnumExtensions.HasFlagFast() instead. M:Realms.IRealmCollection`1.SubscribeForNotifications`1(Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IRealmCollection,NotificationCallbackDelegate) instead. +M:System.Guid.#ctor;Probably meaning to use Guid.NewGuid() instead. If actually wanting empty, use Guid.Empty. M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Linq.IQueryable{``0},Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IQueryable,NotificationCallbackDelegate) instead. M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Collections.Generic.IList{``0},Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IList,NotificationCallbackDelegate) instead. M:System.Threading.Tasks.Task.Wait();Don't use Task.Wait. Use Task.WaitSafely() to ensure we avoid deadlocks. diff --git a/osu.Android.props b/osu.Android.props index 27fcdd4f6e..7910100b1f 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -56,6 +56,6 @@ - + diff --git a/osu.Desktop/osu.nuspec b/osu.Desktop/osu.nuspec index dc1ec17e2c..db58c325bd 100644 --- a/osu.Desktop/osu.nuspec +++ b/osu.Desktop/osu.nuspec @@ -18,5 +18,6 @@ + diff --git a/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist b/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist index 33ddac6dfb..16a2b99997 100644 --- a/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist @@ -37,6 +37,8 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + UIApplicationSupportsIndirectInputEvents + CADisableMinimumFrameDurationOnPhone diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs index 981efc9a13..b1adc4901c 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor { public class TestSceneJuiceStreamPlacementBlueprint : CatchPlacementBlueprintTestScene { - private const double velocity = 0.5; + private const double velocity_factor = 0.5; private JuiceStream lastObject => LastObject?.HitObject as JuiceStream; @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor { var playable = base.GetPlayableBeatmap(); playable.Difficulty.SliderTickRate = 5; - playable.Difficulty.SliderMultiplier = velocity * 10; + playable.Difficulty.SliderMultiplier = velocity_factor * 10; return playable; } @@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor 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])); + AddAssert("default slider velocity", () => lastObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault); } [Test] @@ -66,28 +67,21 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor } [Test] - public void TestVelocityLimit() + public void TestSliderVelocityChange() { double[] times = { 100, 300 }; float[] positions = { 200, 500 }; addPlacementSteps(times, positions); - addPathCheckStep(times, new float[] { 200, 300 }); - } + addPathCheckStep(times, positions); - [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 }); + AddAssert("slider velocity changed", () => !lastObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault); } [Test] public void TestClampedPositionIsRestored() { double[] times = { 100, 300, 500 }; - float[] positions = { 200, 200, 0, 250 }; + float[] positions = { 200, 200, -3000, 250 }; addMoveAndClickSteps(times[0], positions[0]); addMoveAndClickSteps(times[1], positions[1]); @@ -97,15 +91,6 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor 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() { diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs index fb77fb1efd..22a839d847 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs @@ -101,31 +101,16 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor } [Test] - public void TestClampedPositionIsRestored() + public void TestSliderVelocityChange() { - const double velocity = 0.25; - double[] times = { 100, 500, 700 }; - float[] positions = { 100, 100, 100 }; - addBlueprintStep(times, positions, velocity); + double[] times = { 100, 300 }; + float[] positions = { 200, 300 }; + addBlueprintStep(times, positions); + AddAssert("default slider velocity", () => hitObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault); addDragStartStep(times[1], positions[1]); - - AddMouseMoveStep(times[1], 200); - addVertexCheckStep(3, 1, times[1], 200); - addVertexCheckStep(3, 2, times[2], 150); - - AddMouseMoveStep(times[1], 100); - addVertexCheckStep(3, 1, times[1], 100); - // Stored position is restored. - addVertexCheckStep(3, 2, times[2], positions[2]); - - AddMouseMoveStep(times[1], 300); - addDragEndStep(); - addDragStartStep(times[1], 300); - - AddMouseMoveStep(times[1], 100); - // Position is different because a changed position is committed when the previous drag is ended. - addVertexCheckStep(3, 2, times[2], 250); + AddMouseMoveStep(times[1], 400); + AddAssert("slider velocity changed", () => !hitObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault); } [Test] @@ -174,7 +159,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor addAddVertexSteps(500, 150); addVertexCheckStep(3, 1, 500, 150); - addAddVertexSteps(90, 220); + addAddVertexSteps(90, 200); addVertexCheckStep(4, 1, times[0], positions[0]); addAddVertexSteps(750, 180); @@ -234,10 +219,10 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor { var path = new JuiceStreamPath(); for (int i = 1; i < times.Length; i++) - path.Add((times[i] - times[0]) * velocity, positions[i] - positions[0]); + path.Add(times[i] - times[0], positions[i] - positions[0]); var sliderPath = new SliderPath(); - path.ConvertToSliderPath(sliderPath, 0); + path.ConvertToSliderPath(sliderPath, 0, velocity); addBlueprintStep(times[0], positions[0], sliderPath, velocity); } @@ -245,11 +230,11 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor private void addVertexCheckStep(int count, int index, double time, float x) => AddAssert($"vertex {index} of {count} at {time}, {x}", () => { - double expectedDistance = (time - hitObject.StartTime) * hitObject.Velocity; + double expectedTime = time - hitObject.StartTime; float expectedX = x - hitObject.OriginalX; var vertices = getVertices(); return vertices.Count == count && - Precision.AlmostEquals(vertices[index].Distance, expectedDistance, 1e-3) && + Precision.AlmostEquals(vertices[index].Time, expectedTime, 1e-3) && Precision.AlmostEquals(vertices[index].X, expectedX); }); diff --git a/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs b/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs index 8fa96fb8c9..5248d5a96a 100644 --- a/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Utils; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -37,14 +36,14 @@ namespace osu.Game.Rulesets.Catch.Tests { case 0: { - double distance = rng.NextDouble() * scale * 2 - scale; + double time = rng.NextDouble() * scale * 2 - scale; if (integralValues) - distance = Math.Round(distance); + time = Math.Round(time); - float oldX = path.PositionAtDistance(distance); - int index = path.InsertVertex(distance); + float oldX = path.PositionAtTime(time); + int index = path.InsertVertex(time); Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount + 1)); - Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance)); + Assert.That(path.Vertices[index].Time, Is.EqualTo(time)); Assert.That(path.Vertices[index].X, Is.EqualTo(oldX)); break; } @@ -52,20 +51,20 @@ namespace osu.Game.Rulesets.Catch.Tests case 1: { int index = rng.Next(path.Vertices.Count); - double distance = path.Vertices[index].Distance; + double time = path.Vertices[index].Time; float newX = (float)(rng.NextDouble() * scale * 2 - scale); if (integralValues) newX = MathF.Round(newX); path.SetVertexPosition(index, newX); Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount)); - Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance)); + Assert.That(path.Vertices[index].Time, Is.EqualTo(time)); Assert.That(path.Vertices[index].X, Is.EqualTo(newX)); break; } } - assertInvariants(path.Vertices, checkSlope); + assertInvariants(path.Vertices); } } @@ -76,7 +75,7 @@ namespace osu.Game.Rulesets.Catch.Tests path.Add(10, 5); path.Add(20, -5); - int removeCount = path.RemoveVertices((v, i) => v.Distance == 10 && i == 1); + int removeCount = path.RemoveVertices((v, i) => v.Time == 10 && i == 1); Assert.That(removeCount, Is.EqualTo(1)); Assert.That(path.Vertices, Is.EqualTo(new[] { @@ -131,8 +130,9 @@ namespace osu.Game.Rulesets.Catch.Tests })); } - [Test] - public void TestRandomConvertFromSliderPath() + [TestCase(10)] + [TestCase(0.1)] + public void TestRandomConvertFromSliderPath(double velocity) { var rng = new Random(1); var path = new JuiceStreamPath(); @@ -162,28 +162,28 @@ namespace osu.Game.Rulesets.Catch.Tests else sliderPath.ExpectedDistance.Value = null; - path.ConvertFromSliderPath(sliderPath); - Assert.That(path.Vertices[0].Distance, Is.EqualTo(0)); - Assert.That(path.Distance, Is.EqualTo(sliderPath.Distance).Within(1e-3)); - assertInvariants(path.Vertices, true); + path.ConvertFromSliderPath(sliderPath, velocity); + Assert.That(path.Vertices[0].Time, Is.EqualTo(0)); + Assert.That(path.Duration * velocity, Is.EqualTo(sliderPath.Distance).Within(1e-3)); + assertInvariants(path.Vertices); - double[] sampleDistances = Enumerable.Range(0, 10) - .Select(_ => rng.NextDouble() * sliderPath.Distance) - .ToArray(); + double[] sampleTimes = Enumerable.Range(0, 10) + .Select(_ => rng.NextDouble() * sliderPath.Distance / velocity) + .ToArray(); - foreach (double distance in sampleDistances) + foreach (double time in sampleTimes) { - float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X; - Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3)); + float expected = sliderPath.PositionAt(time * velocity / sliderPath.Distance).X; + Assert.That(path.PositionAtTime(time), Is.EqualTo(expected).Within(1e-3)); } - path.ResampleVertices(sampleDistances); - assertInvariants(path.Vertices, true); + path.ResampleVertices(sampleTimes); + assertInvariants(path.Vertices); - foreach (double distance in sampleDistances) + foreach (double time in sampleTimes) { - float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X; - Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3)); + float expected = sliderPath.PositionAt(time * velocity / sliderPath.Distance).X; + Assert.That(path.PositionAtTime(time), Is.EqualTo(expected).Within(1e-3)); } } } @@ -201,17 +201,17 @@ namespace osu.Game.Rulesets.Catch.Tests do { - double distance = rng.NextDouble() * 1e3; + double time = rng.NextDouble() * 1e3; float x = (float)(rng.NextDouble() * 1e3); - path.Add(distance, x); + path.Add(time, x); } while (rng.Next(5) != 0); float sliderStartY = (float)(rng.NextDouble() * JuiceStreamPath.OSU_PLAYFIELD_HEIGHT); - path.ConvertToSliderPath(sliderPath, sliderStartY); - Assert.That(sliderPath.Distance, Is.EqualTo(path.Distance).Within(1e-3)); - Assert.That(sliderPath.ControlPoints[0].Position.X, Is.EqualTo(path.Vertices[0].X)); - assertInvariants(path.Vertices, true); + double requiredVelocity = path.ComputeRequiredVelocity(); + double velocity = Math.Clamp(requiredVelocity, 1, 100); + + path.ConvertToSliderPath(sliderPath, sliderStartY, velocity); foreach (var point in sliderPath.ControlPoints) { @@ -219,11 +219,18 @@ namespace osu.Game.Rulesets.Catch.Tests Assert.That(sliderStartY + point.Position.Y, Is.InRange(0, JuiceStreamPath.OSU_PLAYFIELD_HEIGHT)); } + Assert.That(sliderPath.ControlPoints[0].Position.X, Is.EqualTo(path.Vertices[0].X)); + + // The path is preserved only if required velocity is used. + if (velocity < requiredVelocity) continue; + + Assert.That(sliderPath.Distance / velocity, Is.EqualTo(path.Duration).Within(1e-3)); + for (int i = 0; i < 10; i++) { - double distance = rng.NextDouble() * path.Distance; - float expected = path.PositionAtDistance(distance); - Assert.That(sliderPath.PositionAt(distance / sliderPath.Distance).X, Is.EqualTo(expected).Within(1e-3)); + double time = rng.NextDouble() * path.Duration; + float expected = path.PositionAtTime(time); + Assert.That(sliderPath.PositionAt(time * velocity / sliderPath.Distance).X, Is.EqualTo(expected).Within(3e-3)); } } } @@ -244,7 +251,7 @@ namespace osu.Game.Rulesets.Catch.Tests path.Add(20, 0); checkNewId(); - path.RemoveVertices((v, _) => v.Distance == 20); + path.RemoveVertices((v, _) => v.Time == 20); checkNewId(); path.ResampleVertices(new double[] { 5, 10, 15 }); @@ -253,7 +260,7 @@ namespace osu.Game.Rulesets.Catch.Tests path.Clear(); checkNewId(); - path.ConvertFromSliderPath(new SliderPath()); + path.ConvertFromSliderPath(new SliderPath(), 1); checkNewId(); void checkNewId() @@ -263,25 +270,19 @@ namespace osu.Game.Rulesets.Catch.Tests } } - private void assertInvariants(IReadOnlyList vertices, bool checkSlope) + private void assertInvariants(IReadOnlyList vertices) { Assert.That(vertices, Is.Not.Empty); for (int i = 0; i < vertices.Count; i++) { - Assert.That(double.IsFinite(vertices[i].Distance)); + Assert.That(double.IsFinite(vertices[i].Time)); Assert.That(float.IsFinite(vertices[i].X)); } for (int i = 1; i < vertices.Count; i++) { - Assert.That(vertices[i].Distance, Is.GreaterThanOrEqualTo(vertices[i - 1].Distance)); - - if (!checkSlope) continue; - - float xDiff = Math.Abs(vertices[i].X - vertices[i - 1].X); - double distanceDiff = vertices[i].Distance - vertices[i - 1].Distance; - Assert.That(xDiff, Is.LessThanOrEqualTo(distanceDiff).Within(Precision.FLOAT_EPSILON)); + Assert.That(vertices[i].Time, Is.GreaterThanOrEqualTo(vertices[i - 1].Time)); } } } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs index 1a43a10c81..e038562b4b 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components public int VertexCount => path.Vertices.Count; - protected readonly Func PositionToDistance; + protected readonly Func PositionToTime; protected IReadOnlyList VertexStates => vertexStates; @@ -44,9 +44,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components [CanBeNull] private IBeatSnapProvider beatSnapProvider { get; set; } - protected EditablePath(Func positionToDistance) + protected EditablePath(Func positionToTime) { - PositionToDistance = positionToDistance; + PositionToTime = positionToTime; Anchor = Anchor.BottomLeft; } @@ -59,13 +59,13 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components while (InternalChildren.Count < path.Vertices.Count) AddInternal(new VertexPiece()); - double distanceToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1 / hitObject.Velocity); + double timeToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1); for (int i = 0; i < VertexCount; i++) { var piece = (VertexPiece)InternalChildren[i]; var vertex = path.Vertices[i]; - piece.Position = new Vector2(vertex.X, (float)(vertex.Distance * distanceToYFactor)); + piece.Position = new Vector2(vertex.X, (float)(vertex.Time * timeToYFactor)); piece.UpdateFrom(vertexStates[i]); } } @@ -73,14 +73,14 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components public void InitializeFromHitObject(JuiceStream hitObject) { var sliderPath = hitObject.Path; - path.ConvertFromSliderPath(sliderPath); + path.ConvertFromSliderPath(sliderPath, hitObject.Velocity); // If the original slider path has non-linear type segments, resample the vertices at nested hit object times to reduce the number of vertices. if (sliderPath.ControlPoints.Any(p => p.Type != null && p.Type != PathType.Linear)) { path.ResampleVertices(hitObject.NestedHitObjects .Skip(1).TakeWhile(h => !(h is Fruit)) // Only droplets in the first span are used. - .Select(h => (h.StartTime - hitObject.StartTime) * hitObject.Velocity)); + .Select(h => h.StartTime - hitObject.StartTime)); } vertexStates.Clear(); @@ -92,11 +92,26 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components public void UpdateHitObjectFromPath(JuiceStream hitObject) { - path.ConvertToSliderPath(hitObject.Path, hitObject.LegacyConvertedY); + // The SV setting may need to be changed for the current path. + var svBindable = hitObject.DifficultyControlPoint.SliderVelocityBindable; + double svToVelocityFactor = hitObject.Velocity / svBindable.Value; + double requiredVelocity = path.ComputeRequiredVelocity(); + + // The value is pre-rounded here because setting it to the bindable will rounded to the nearest value + // but it should be always rounded up to satisfy the required minimum velocity condition. + // + // This is rounded to integers instead of using the precision of the bindable + // because it results in a smaller number of non-redundant control points. + // + // The value is clamped here by the bindable min and max values. + // In case the required velocity is too large, the path is not preserved. + svBindable.Value = Math.Ceiling(requiredVelocity / svToVelocityFactor); + + path.ConvertToSliderPath(hitObject.Path, hitObject.LegacyConvertedY, hitObject.Velocity); if (beatSnapProvider == null) return; - double endTime = hitObject.StartTime + path.Distance / hitObject.Velocity; + double endTime = hitObject.StartTime + path.Duration; double snappedEndTime = beatSnapProvider.SnapTime(endTime, hitObject.StartTime); hitObject.Path.ExpectedDistance.Value = (snappedEndTime - hitObject.StartTime) * hitObject.Velocity; } @@ -108,9 +123,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; - protected int AddVertex(double distance, float x) + protected int AddVertex(double time, float x) { - int index = path.InsertVertex(distance); + int index = path.InsertVertex(time); path.SetVertexPosition(index, x); vertexStates.Insert(index, new VertexState()); @@ -138,9 +153,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components return true; } - protected void MoveSelectedVertices(double distanceDelta, float xDelta) + protected void MoveSelectedVertices(double timeDelta, float xDelta) { - // Because the vertex list may be reordered due to distance change, the state list must be reordered as well. + // Because the vertex list may be reordered due to time change, the state list must be reordered as well. previousVertexStates.Clear(); previousVertexStates.AddRange(vertexStates); @@ -152,11 +167,11 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components for (int i = 1; i < vertexCount; i++) { var state = previousVertexStates[i]; - double distance = state.VertexBeforeChange.Distance; + double time = state.VertexBeforeChange.Time; if (state.IsSelected) - distance += distanceDelta; + time += timeDelta; - int newIndex = path.InsertVertex(Math.Max(0, distance)); + int newIndex = path.InsertVertex(Math.Max(0, time)); vertexStates.Insert(newIndex, state); } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs index 158872fbab..511aec5e5d 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs @@ -15,15 +15,15 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components /// private JuiceStreamPathVertex lastVertex; - public PlacementEditablePath(Func positionToDistance) - : base(positionToDistance) + public PlacementEditablePath(Func positionToTime) + : base(positionToTime) { } public void AddNewVertex() { var endVertex = Vertices[^1]; - int index = AddVertex(endVertex.Distance, endVertex.X); + int index = AddVertex(endVertex.Time, endVertex.X); for (int i = 0; i < VertexCount; i++) { @@ -41,9 +41,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components public void MoveLastVertex(Vector2 screenSpacePosition) { Vector2 position = ToRelativePosition(screenSpacePosition); - double distanceDelta = PositionToDistance(position.Y) - lastVertex.Distance; + double timeDelta = PositionToTime(position.Y) - lastVertex.Time; float xDelta = position.X - lastVertex.X; - MoveSelectedVertices(distanceDelta, xDelta); + MoveSelectedVertices(timeDelta, xDelta); } } } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs index 109bf61ea5..cfaca2f9a4 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { private readonly Path drawablePath; - private readonly List<(double Distance, float X)> vertices = new List<(double, float)>(); + private readonly List<(double Time, float X)> vertices = new List<(double, float)>(); public ScrollingPath() { @@ -35,16 +35,16 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components public void UpdatePathFrom(ScrollingHitObjectContainer hitObjectContainer, JuiceStream hitObject) { - double distanceToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1 / hitObject.Velocity); + double timeToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1); - computeDistanceXs(hitObject); + computeTimeXs(hitObject); drawablePath.Vertices = vertices - .Select(v => new Vector2(v.X, (float)(v.Distance * distanceToYFactor))) + .Select(v => new Vector2(v.X, (float)(v.Time * timeToYFactor))) .ToArray(); drawablePath.OriginPosition = drawablePath.PositionInBoundingBox(Vector2.Zero); } - private void computeDistanceXs(JuiceStream hitObject) + private void computeTimeXs(JuiceStream hitObject) { vertices.Clear(); @@ -54,17 +54,17 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components if (sliderVertices.Count == 0) return; - double distance = 0; + double time = 0; Vector2 lastPosition = Vector2.Zero; for (int repeat = 0; repeat < hitObject.RepeatCount + 1; repeat++) { foreach (var position in sliderVertices) { - distance += Vector2.Distance(lastPosition, position); + time += Vector2.Distance(lastPosition, position) / hitObject.Velocity; lastPosition = position; - vertices.Add((distance, position.X)); + vertices.Add((time, position.X)); } sliderVertices.Reverse(); diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs index 8c7314d0b6..b4c353313c 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs @@ -27,15 +27,15 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components [CanBeNull] private IEditorChangeHandler changeHandler { get; set; } - public SelectionEditablePath(Func positionToDistance) - : base(positionToDistance) + public SelectionEditablePath(Func positionToTime) + : base(positionToTime) { } public void AddVertex(Vector2 relativePosition) { - double distance = Math.Max(0, PositionToDistance(relativePosition.Y)); - int index = AddVertex(distance, relativePosition.X); + double time = Math.Max(0, PositionToTime(relativePosition.Y)); + int index = AddVertex(time, relativePosition.X); selectOnly(index); } @@ -83,9 +83,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components protected override void OnDrag(DragEvent e) { Vector2 mousePosition = ToRelativePosition(e.ScreenSpaceMousePosition); - double distanceDelta = PositionToDistance(mousePosition.Y) - PositionToDistance(dragStartPosition.Y); + double timeDelta = PositionToTime(mousePosition.Y) - PositionToTime(dragStartPosition.Y); float xDelta = mousePosition.X - dragStartPosition.X; - MoveSelectedVertices(distanceDelta, xDelta); + MoveSelectedVertices(timeDelta, xDelta); } protected override void OnDragEnd(DragEndEvent e) diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs index e9c8e2bb2c..b5dcb62543 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints { scrollingPath = new ScrollingPath(), nestedOutlineContainer = new NestedOutlineContainer(), - editablePath = new PlacementEditablePath(positionToDistance) + editablePath = new PlacementEditablePath(positionToTime) }; } @@ -121,10 +121,10 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints lastEditablePathId = editablePath.PathId; } - private double positionToDistance(float relativeYPosition) + private double positionToTime(float relativeYPosition) { double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime); - return (time - HitObject.StartTime) * HitObject.Velocity; + return time - HitObject.StartTime; } } } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs index 890d059d19..12054a1d16 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs @@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints { scrollingPath = new ScrollingPath(), nestedOutlineContainer = new NestedOutlineContainer(), - editablePath = new SelectionEditablePath(positionToDistance) + editablePath = new SelectionEditablePath(positionToTime) }; } @@ -145,10 +145,10 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints return new RectangleF(left, top, right - left, bottom - top).Inflate(objectRadius); } - private double positionToDistance(float relativeYPosition) + private double positionToTime(float relativeYPosition) { double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime); - return (time - HitObject.StartTime) * HitObject.Velocity; + return time - HitObject.StartTime; } private void initializeJuiceStreamPath() diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index 630a2cf645..6f59b3e543 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -6,6 +6,7 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; @@ -89,15 +90,19 @@ namespace osu.Game.Rulesets.Catch.Edit new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) }); - public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) + public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) { - var result = base.FindSnappedPositionAndTime(screenSpacePosition); + var result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType); + result.ScreenSpacePosition.X = screenSpacePosition.X; - if (distanceSnapGrid.IsPresent && distanceSnapGrid.GetSnappedPosition(result.ScreenSpacePosition) is SnapResult snapResult && - Vector2.Distance(snapResult.ScreenSpacePosition, result.ScreenSpacePosition) < distance_snap_radius) + if (snapType.HasFlagFast(SnapType.Grids)) { - result = snapResult; + if (distanceSnapGrid.IsPresent && distanceSnapGrid.GetSnappedPosition(result.ScreenSpacePosition) is SnapResult snapResult && + Vector2.Distance(snapResult.ScreenSpacePosition, result.ScreenSpacePosition) < distance_snap_radius) + { + result = snapResult; + } } return result; diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index 282afb6343..d34452cdbb 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -27,10 +27,16 @@ namespace osu.Game.Rulesets.Catch.Objects public int RepeatCount { get; set; } [JsonIgnore] - public double Velocity { get; private set; } + private double velocityFactor; [JsonIgnore] - public double TickDistance { get; private set; } + private double tickDistanceFactor; + + [JsonIgnore] + public double Velocity => velocityFactor * DifficultyControlPoint.SliderVelocity; + + [JsonIgnore] + public double TickDistance => tickDistanceFactor * DifficultyControlPoint.SliderVelocity; /// /// The length of one span of this . @@ -43,10 +49,8 @@ namespace osu.Game.Rulesets.Catch.Objects TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); - double scoringDistance = base_scoring_distance * difficulty.SliderMultiplier * DifficultyControlPoint.SliderVelocity; - - Velocity = scoringDistance / timingPoint.BeatLength; - TickDistance = scoringDistance / difficulty.SliderTickRate; + velocityFactor = base_scoring_distance * difficulty.SliderMultiplier / timingPoint.BeatLength; + tickDistanceFactor = base_scoring_distance * difficulty.SliderMultiplier / difficulty.SliderTickRate; } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs index 7207833fe6..61f4c580ae 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs @@ -20,11 +20,6 @@ namespace osu.Game.Rulesets.Catch.Objects /// However, the representation is difficult to work with. /// This represents the path in a more convenient way, a polyline connecting list of s. /// - /// - /// The path can be regarded as a function from the closed interval [Vertices[0].Distance, Vertices[^1].Distance] to the x position, given by . - /// To ensure the path is convertible to a , the slope of the function must not be more than 1 everywhere, - /// and this slope condition is always maintained as an invariant. - /// /// public class JuiceStreamPath { @@ -46,9 +41,9 @@ namespace osu.Game.Rulesets.Catch.Objects public int InvalidationID { get; private set; } = 1; /// - /// The difference between first vertex's and last vertex's . + /// The difference between first vertex's and last vertex's . /// - public double Distance => vertices[^1].Distance - vertices[0].Distance; + public double Duration => vertices[^1].Time - vertices[0].Time; /// /// This list should always be non-empty. @@ -59,15 +54,15 @@ namespace osu.Game.Rulesets.Catch.Objects }; /// - /// Compute the x-position of the path at the given . + /// Compute the x-position of the path at the given . /// /// - /// When the given distance is outside of the path, the x position at the corresponding endpoint is returned, + /// When the given time is outside of the path, the x position at the corresponding endpoint is returned, /// - public float PositionAtDistance(double distance) + public float PositionAtTime(double time) { - int index = vertexIndexAtDistance(distance); - return positionAtDistance(distance, index); + int index = vertexIndexAtTime(time); + return positionAtTime(time, index); } /// @@ -81,19 +76,19 @@ namespace osu.Game.Rulesets.Catch.Objects } /// - /// Insert a vertex at given . - /// The is used as the position of the new vertex. + /// Insert a vertex at given . + /// The is used as the position of the new vertex. /// Thus, the set of points of the path is not changed (up to floating-point precision). /// /// The index of the new vertex. - public int InsertVertex(double distance) + public int InsertVertex(double time) { - if (!double.IsFinite(distance)) - throw new ArgumentOutOfRangeException(nameof(distance)); + if (!double.IsFinite(time)) + throw new ArgumentOutOfRangeException(nameof(time)); - int index = vertexIndexAtDistance(distance); - float x = positionAtDistance(distance, index); - vertices.Insert(index, new JuiceStreamPathVertex(distance, x)); + int index = vertexIndexAtTime(time); + float x = positionAtTime(time, index); + vertices.Insert(index, new JuiceStreamPathVertex(time, x)); invalidate(); return index; @@ -101,7 +96,6 @@ namespace osu.Game.Rulesets.Catch.Objects /// /// Move the vertex of given to the given position . - /// When the distances between vertices are too small for the new vertex positions, the adjacent vertices are moved towards . /// public void SetVertexPosition(int index, float newX) { @@ -111,32 +105,17 @@ namespace osu.Game.Rulesets.Catch.Objects if (!float.IsFinite(newX)) throw new ArgumentOutOfRangeException(nameof(newX)); - var newVertex = new JuiceStreamPathVertex(vertices[index].Distance, newX); - - for (int i = index - 1; i >= 0 && !canConnect(vertices[i], newVertex); i--) - { - float clampedX = clampToConnectablePosition(newVertex, vertices[i]); - vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX); - } - - for (int i = index + 1; i < vertices.Count; i++) - { - float clampedX = clampToConnectablePosition(newVertex, vertices[i]); - vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX); - } - - vertices[index] = newVertex; + vertices[index] = new JuiceStreamPathVertex(vertices[index].Time, newX); invalidate(); } /// - /// Add a new vertex at given and position. - /// Adjacent vertices are moved when necessary in the same way as . + /// Add a new vertex at given and position. /// - public void Add(double distance, float x) + public void Add(double time, float x) { - int index = InsertVertex(distance); + int index = InsertVertex(time); SetVertexPosition(index, x); } @@ -163,22 +142,22 @@ namespace osu.Game.Rulesets.Catch.Objects } /// - /// Recreate this path by using difference set of vertices at given distances. - /// In addition to the given , the first vertex and the last vertex are always added to the new path. - /// New vertices use the positions on the original path. Thus, s at are preserved. + /// Recreate this path by using difference set of vertices at given time points. + /// In addition to the given , the first vertex and the last vertex are always added to the new path. + /// New vertices use the positions on the original path. Thus, s at are preserved. /// - public void ResampleVertices(IEnumerable sampleDistances) + public void ResampleVertices(IEnumerable sampleTimes) { var sampledVertices = new List(); - foreach (double distance in sampleDistances) + foreach (double time in sampleTimes) { - if (!double.IsFinite(distance)) - throw new ArgumentOutOfRangeException(nameof(sampleDistances)); + if (!double.IsFinite(time)) + throw new ArgumentOutOfRangeException(nameof(sampleTimes)); - double clampedDistance = Math.Clamp(distance, vertices[0].Distance, vertices[^1].Distance); - float x = PositionAtDistance(clampedDistance); - sampledVertices.Add(new JuiceStreamPathVertex(clampedDistance, x)); + double clampedTime = Math.Clamp(time, vertices[0].Time, vertices[^1].Time); + float x = PositionAtTime(clampedTime); + sampledVertices.Add(new JuiceStreamPathVertex(clampedTime, x)); } sampledVertices.Sort(); @@ -196,37 +175,62 @@ namespace osu.Game.Rulesets.Catch.Objects /// /// Duplicated vertices are automatically removed. /// - public void ConvertFromSliderPath(SliderPath sliderPath) + public void ConvertFromSliderPath(SliderPath sliderPath, double velocity) { var sliderPathVertices = new List(); sliderPath.GetPathToProgress(sliderPathVertices, 0, 1); - double distance = 0; + double time = 0; vertices.Clear(); vertices.Add(new JuiceStreamPathVertex(0, sliderPathVertices.FirstOrDefault().X)); for (int i = 1; i < sliderPathVertices.Count; i++) { - distance += Vector2.Distance(sliderPathVertices[i - 1], sliderPathVertices[i]); + time += Vector2.Distance(sliderPathVertices[i - 1], sliderPathVertices[i]) / velocity; - if (!Precision.AlmostEquals(vertices[^1].Distance, distance)) - vertices.Add(new JuiceStreamPathVertex(distance, sliderPathVertices[i].X)); + if (!Precision.AlmostEquals(vertices[^1].Time, time)) + Add(time, sliderPathVertices[i].X); } invalidate(); } + /// + /// Computes the minimum slider velocity required to convert this path to a . + /// + public double ComputeRequiredVelocity() + { + double maximumSlope = 0; + + for (int i = 1; i < vertices.Count; i++) + { + double xDifference = Math.Abs((double)vertices[i].X - vertices[i - 1].X); + double timeDifference = vertices[i].Time - vertices[i - 1].Time; + + // A short segment won't affect the resulting path much anyways so ignore it to avoid divide-by-zero. + if (Precision.AlmostEquals(timeDifference, 0)) + continue; + + maximumSlope = Math.Max(maximumSlope, xDifference / timeDifference); + } + + return maximumSlope; + } + /// /// Convert the path of this to a and write the result to . /// The resulting slider is "folded" to make it vertically contained in the playfield `(0..)` assuming the slider start position is . + /// + /// The velocity of the converted slider is assumed to be . + /// To preserve the path, should be at least the value returned by . /// - public void ConvertToSliderPath(SliderPath sliderPath, float sliderStartY) + public void ConvertToSliderPath(SliderPath sliderPath, float sliderStartY, double velocity) { const float margin = 1; // Note: these two variables and `sliderPath` are modified by the local functions. - double currentDistance = 0; + double currentTime = 0; Vector2 lastPosition = new Vector2(vertices[0].X, 0); sliderPath.ControlPoints.Clear(); @@ -237,10 +241,10 @@ namespace osu.Game.Rulesets.Catch.Objects sliderPath.ControlPoints[^1].Type = PathType.Linear; float deltaX = vertices[i].X - lastPosition.X; - double length = vertices[i].Distance - currentDistance; + double length = (vertices[i].Time - currentTime) * velocity; // Should satisfy `deltaX^2 + deltaY^2 = length^2`. - // By invariants, the expression inside the `sqrt` is (almost) non-negative. + // The expression inside the `sqrt` is (almost) non-negative if the slider velocity is large enough. double deltaY = Math.Sqrt(Math.Max(0, length * length - (double)deltaX * deltaX)); // When `deltaY` is small, one segment is always enough. @@ -280,59 +284,38 @@ namespace osu.Game.Rulesets.Catch.Objects { Vector2 nextPosition = new Vector2(nextX, nextY); sliderPath.ControlPoints.Add(new PathControlPoint(nextPosition)); - currentDistance += Vector2.Distance(lastPosition, nextPosition); + currentTime += Vector2.Distance(lastPosition, nextPosition) / velocity; lastPosition = nextPosition; } } /// - /// Find the index at which a new vertex with can be inserted. + /// Find the index at which a new vertex with can be inserted. /// - private int vertexIndexAtDistance(double distance) + private int vertexIndexAtTime(double time) { - // The position of `(distance, Infinity)` is uniquely determined because infinite positions are not allowed. - int i = vertices.BinarySearch(new JuiceStreamPathVertex(distance, float.PositiveInfinity)); + // The position of `(time, Infinity)` is uniquely determined because infinite positions are not allowed. + int i = vertices.BinarySearch(new JuiceStreamPathVertex(time, float.PositiveInfinity)); return i < 0 ? ~i : i; } /// - /// Compute the position at the given , assuming is the vertex index returned by . + /// Compute the position at the given , assuming is the vertex index returned by . /// - private float positionAtDistance(double distance, int index) + private float positionAtTime(double time, int index) { if (index <= 0) return vertices[0].X; if (index >= vertices.Count) return vertices[^1].X; - double length = vertices[index].Distance - vertices[index - 1].Distance; - if (Precision.AlmostEquals(length, 0)) + double duration = vertices[index].Time - vertices[index - 1].Time; + if (Precision.AlmostEquals(duration, 0)) return vertices[index].X; float deltaX = vertices[index].X - vertices[index - 1].X; - return (float)(vertices[index - 1].X + deltaX * ((distance - vertices[index - 1].Distance) / length)); - } - - /// - /// Check the two vertices can connected directly while satisfying the slope condition. - /// - private bool canConnect(JuiceStreamPathVertex vertex1, JuiceStreamPathVertex vertex2, float allowance = 0) - { - double xDistance = Math.Abs((double)vertex2.X - vertex1.X); - float length = (float)Math.Abs(vertex2.Distance - vertex1.Distance); - return xDistance <= length + allowance; - } - - /// - /// Move the position of towards the position of - /// until the vertex pair satisfies the condition . - /// - /// The resulting position of . - private float clampToConnectablePosition(JuiceStreamPathVertex fixedVertex, JuiceStreamPathVertex movableVertex) - { - float length = (float)Math.Abs(movableVertex.Distance - fixedVertex.Distance); - return Math.Clamp(movableVertex.X, fixedVertex.X - length, fixedVertex.X + length); + return (float)(vertices[index - 1].X + deltaX * ((time - vertices[index - 1].Time) / duration)); } private void invalidate() => InvalidationID++; diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs index 58c50603c4..afef2e637f 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs @@ -12,22 +12,22 @@ namespace osu.Game.Rulesets.Catch.Objects /// public readonly struct JuiceStreamPathVertex : IComparable { - public readonly double Distance; + public readonly double Time; public readonly float X; - public JuiceStreamPathVertex(double distance, float x) + public JuiceStreamPathVertex(double time, float x) { - Distance = distance; + Time = time; X = x; } public int CompareTo(JuiceStreamPathVertex other) { - int c = Distance.CompareTo(other.Distance); + int c = Time.CompareTo(other.Time); return c != 0 ? c : X.CompareTo(other.X); } - public override string ToString() => $"({Distance}, {X})"; + public override string ToString() => $"({Time}, {X})"; } } diff --git a/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist b/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist index 78349334b4..82d1c8ea24 100644 --- a/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist @@ -37,6 +37,8 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + UIApplicationSupportsIndirectInputEvents + CADisableMinimumFrameDurationOnPhone diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs index 4bb049b1a4..6130a80bb4 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs @@ -97,12 +97,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor set => InternalChild = value; } - public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) - { - throw new System.NotImplementedException(); - } - - public override SnapResult FindSnappedPosition(Vector2 screenSpacePosition) + public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) { throw new System.NotImplementedException(); } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs index 8f25668dd0..7a99565e8a 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs @@ -5,7 +5,10 @@ using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints @@ -52,8 +55,29 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { base.UpdateTimeAndPosition(result); - if (PlacementActive == PlacementState.Waiting) - Column = result.Playfield as Column; + if (result.Playfield is Column col) + { + // Apply an offset to better align with the visual grid. + // This should only be applied during placement, as during selection / drag operations the movement is relative + // to the initial point of interaction rather than the grid. + switch (col.ScrollingInfo.Direction.Value) + { + case ScrollingDirection.Down: + result.ScreenSpacePosition -= new Vector2(0, getNoteHeight(col) / 2); + break; + + case ScrollingDirection.Up: + result.ScreenSpacePosition += new Vector2(0, getNoteHeight(col) / 2); + break; + } + + if (PlacementActive == PlacementState.Waiting) + Column = col; + } } + + private float getNoteHeight(Column resultPlayfield) => + resultPlayfield.ToScreenSpace(new Vector2(DefaultNotePiece.NOTE_HEIGHT)).Y - + resultPlayfield.ToScreenSpace(Vector2.Zero).Y; } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index fef315e2ef..c389e1bced 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -1,15 +1,14 @@ // 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.Rulesets.Edit; -using osu.Game.Rulesets.Edit.Tools; -using osu.Game.Rulesets.Mania.Objects; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Input; -using osu.Game.Rulesets.Mania.Skinning.Default; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -56,28 +55,6 @@ namespace osu.Game.Rulesets.Mania.Edit protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => Playfield.GetColumnByPosition(screenSpacePosition); - public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) - { - var result = base.FindSnappedPositionAndTime(screenSpacePosition); - - switch (ScrollingInfo.Direction.Value) - { - case ScrollingDirection.Down: - result.ScreenSpacePosition -= new Vector2(0, getNoteHeight() / 2); - break; - - case ScrollingDirection.Up: - result.ScreenSpacePosition += new Vector2(0, getNoteHeight() / 2); - break; - } - - return result; - } - - private float getNoteHeight() => - Playfield.GetColumn(0).ToScreenSpace(new Vector2(DefaultNotePiece.NOTE_HEIGHT)).Y - - Playfield.GetColumn(0).ToScreenSpace(Vector2.Zero).Y; - protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) { drawableRuleset = new DrawableManiaEditorRuleset(ruleset, beatmap, mods); diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist b/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist index b9f371c049..a88b74695c 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist @@ -37,6 +37,8 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + UIApplicationSupportsIndirectInputEvents + CADisableMinimumFrameDurationOnPhone diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 02beb0f2a4..b0d6170190 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -123,33 +124,27 @@ namespace osu.Game.Rulesets.Osu.Edit } } - public override SnapResult FindSnappedPosition(Vector2 screenSpacePosition) + public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) { - if (snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) + if (snapType.HasFlagFast(SnapType.NearbyObjects) && snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) return snapResult; - return new SnapResult(screenSpacePosition, null); - } - - public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) - { - var positionSnap = FindSnappedPosition(screenSpacePosition); - if (positionSnap.ScreenSpacePosition != screenSpacePosition) - return positionSnap; - - if (distanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + if (snapType.HasFlagFast(SnapType.Grids)) { - (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); - return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + if (distanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + { + (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); + return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + } + + if (rectangularGridSnapToggle.Value == TernaryState.True) + { + Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(screenSpacePosition)); + return new SnapResult(rectangularPositionSnapGrid.ToScreenSpace(pos), null, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + } } - if (rectangularGridSnapToggle.Value == TernaryState.True) - { - Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(screenSpacePosition)); - return new SnapResult(rectangularPositionSnapGrid.ToScreenSpace(pos), null, PlayfieldAtScreenSpacePosition(screenSpacePosition)); - } - - return base.FindSnappedPositionAndTime(screenSpacePosition); + return base.FindSnappedPositionAndTime(screenSpacePosition, snapType); } private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult) diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index 391147648f..dd6226e19b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private readonly Bindable accentColour = new Bindable(); private readonly IBindable indexInCurrentCombo = new Bindable(); - [Resolved(canBeNull: true)] + [Resolved(canBeNull: true)] // Can't really be null but required to handle potential of disposal before DI completes. private DrawableHitObject? drawableObject { get; set; } [Resolved] diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist b/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist index 65c47d2115..9628475b3e 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist @@ -37,6 +37,8 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + UIApplicationSupportsIndirectInputEvents + CADisableMinimumFrameDurationOnPhone diff --git a/osu.Game.Tests.iOS/Info.plist b/osu.Game.Tests.iOS/Info.plist index ed0c2e4dbf..31e2b3f257 100644 --- a/osu.Game.Tests.iOS/Info.plist +++ b/osu.Game.Tests.iOS/Info.plist @@ -37,6 +37,8 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + UIApplicationSupportsIndirectInputEvents + CADisableMinimumFrameDurationOnPhone diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index 468cb7683c..89baaf228d 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -863,5 +863,40 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(decoded.Difficulty.OverallDifficulty, Is.EqualTo(1)); } } + + [Test] + public void TestLegacyAdjacentImplicitCatmullSegmentsAreMerged() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("adjacent-catmull-segments.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var decoded = decoder.Decode(stream); + var controlPoints = ((IHasPath)decoded.HitObjects[0]).Path.ControlPoints; + + Assert.That(controlPoints.Count, Is.EqualTo(6)); + Assert.That(controlPoints.Single(c => c.Type != null).Type, Is.EqualTo(PathType.Catmull)); + } + } + + [Test] + public void TestNonLegacyAdjacentImplicitCatmullSegmentsAreNotMerged() + { + var decoder = new LegacyBeatmapDecoder(LegacyBeatmapEncoder.FIRST_LAZER_VERSION) { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("adjacent-catmull-segments.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var decoded = decoder.Decode(stream); + var controlPoints = ((IHasPath)decoded.HitObjects[0]).Path.ControlPoints; + + Assert.That(controlPoints.Count, Is.EqualTo(4)); + Assert.That(controlPoints[0].Type, Is.EqualTo(PathType.Catmull)); + Assert.That(controlPoints[1].Type, Is.EqualTo(PathType.Catmull)); + Assert.That(controlPoints[2].Type, Is.EqualTo(PathType.Catmull)); + Assert.That(controlPoints[3].Type, Is.Null); + } + } } } diff --git a/osu.Game.Tests/Resources/adjacent-catmull-segments.osu b/osu.Game.Tests/Resources/adjacent-catmull-segments.osu new file mode 100644 index 0000000000..a436fe5228 --- /dev/null +++ b/osu.Game.Tests/Resources/adjacent-catmull-segments.osu @@ -0,0 +1,2 @@ +[HitObjects] +200,304,23875,6,0,C|288:304|288:304|288:208|288:208|352:208,1,260,8|0 \ No newline at end of file diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index 3aa3481cbf..ef07c3e411 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -185,10 +185,7 @@ namespace osu.Game.Tests.Visual.Editing private class SnapProvider : IDistanceSnapProvider { - public SnapResult FindSnappedPosition(Vector2 screenSpacePosition) => - new SnapResult(screenSpacePosition, null); - - public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0); + public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.Grids) => new SnapResult(screenSpacePosition, 0); public Bindable DistanceSpacingMultiplier { get; } = new BindableDouble(1); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlayV2.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlayV2.cs index bf1767cc96..3e3c8122b3 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlayV2.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlayV2.cs @@ -5,6 +5,8 @@ using System; using System.Linq; using System.Collections.Generic; using System.Net; +using System.Threading; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -31,7 +33,7 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneChatOverlayV2 : OsuManualInputManagerTestScene { - private ChatOverlayV2 chatOverlay; + private TestChatOverlayV2 chatOverlay; private ChannelManager channelManager; private APIUser testUser; @@ -61,7 +63,7 @@ namespace osu.Game.Tests.Visual.Online Children = new Drawable[] { channelManager, - chatOverlay = new ChatOverlayV2 { RelativeSizeAxes = Axes.Both }, + chatOverlay = new TestChatOverlayV2 { RelativeSizeAxes = Axes.Both }, }, }; }); @@ -365,19 +367,19 @@ namespace osu.Game.Tests.Visual.Online } [Test] - public void TextBoxRetainsFocus() + public void TestTextBoxRetainsFocus() { AddStep("Show overlay", () => chatOverlay.Show()); AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); + AddStep("Click drawable channel", () => clickDrawable(currentDrawableChannel)); + AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); AddStep("Click selector", () => clickDrawable(chatOverlay.ChildrenOfType().Single())); AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); AddStep("Click listing", () => clickDrawable(chatOverlay.ChildrenOfType().Single())); AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); - AddStep("Click drawable channel", () => clickDrawable(currentDrawableChannel)); - AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); AddStep("Click channel list", () => clickDrawable(chatOverlay.ChildrenOfType().Single())); AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); AddStep("Click top bar", () => clickDrawable(chatOverlay.ChildrenOfType().Single())); @@ -386,6 +388,34 @@ namespace osu.Game.Tests.Visual.Online AddAssert("TextBox is not focused", () => InputManager.FocusedDrawable == null); } + [Test] + public void TestSlowLoadingChannel() + { + AddStep("Show overlay (slow-loading)", () => + { + chatOverlay.Show(); + chatOverlay.SlowLoading = true; + }); + AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + AddAssert("Channel 1 loading", () => !channelIsVisible && chatOverlay.GetSlowLoadingChannel(testChannel1).LoadState == LoadState.Loading); + + AddStep("Join channel 2", () => channelManager.JoinChannel(testChannel2)); + AddStep("Select channel 2", () => clickDrawable(getChannelListItem(testChannel2))); + AddAssert("Channel 2 loading", () => !channelIsVisible && chatOverlay.GetSlowLoadingChannel(testChannel2).LoadState == LoadState.Loading); + + AddStep("Finish channel 1 load", () => chatOverlay.GetSlowLoadingChannel(testChannel1).LoadEvent.Set()); + AddAssert("Channel 1 ready", () => chatOverlay.GetSlowLoadingChannel(testChannel1).LoadState == LoadState.Ready); + AddAssert("Channel 1 not displayed", () => !channelIsVisible); + + AddStep("Finish channel 2 load", () => chatOverlay.GetSlowLoadingChannel(testChannel2).LoadEvent.Set()); + AddAssert("Channel 2 loaded", () => chatOverlay.GetSlowLoadingChannel(testChannel2).IsLoaded); + AddAssert("Channel 2 displayed", () => channelIsVisible && currentDrawableChannel.Channel == testChannel2); + + AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); + AddAssert("Channel 1 loaded", () => chatOverlay.GetSlowLoadingChannel(testChannel1).IsLoaded); + AddAssert("Channel 1 displayed", () => channelIsVisible && currentDrawableChannel.Channel == testChannel1); + } + private bool listingIsVisible => chatOverlay.ChildrenOfType().Single().State.Value == Visibility.Visible; @@ -432,5 +462,35 @@ namespace osu.Game.Tests.Visual.Online Topic = $"We talk about the number {id} here", Type = ChannelType.Public, }; + + private class TestChatOverlayV2 : ChatOverlayV2 + { + public bool SlowLoading { get; set; } + + public SlowLoadingDrawableChannel GetSlowLoadingChannel(Channel channel) => DrawableChannels.OfType().Single(c => c.Channel == channel); + + protected override ChatOverlayDrawableChannel CreateDrawableChannel(Channel newChannel) + { + return SlowLoading + ? new SlowLoadingDrawableChannel(newChannel) + : new ChatOverlayDrawableChannel(newChannel); + } + } + + private class SlowLoadingDrawableChannel : ChatOverlayDrawableChannel + { + public readonly ManualResetEventSlim LoadEvent = new ManualResetEventSlim(); + + public SlowLoadingDrawableChannel([NotNull] Channel channel) + : base(channel) + { + } + + [BackgroundDependencyLoader] + private void load() + { + LoadEvent.Wait(10000); + } + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenImportFromStable.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenImportFromStable.cs new file mode 100644 index 0000000000..081b240795 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenImportFromStable.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 System.Threading; +using System.Threading.Tasks; +using Moq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Database; +using osu.Game.Overlays; +using osu.Game.Overlays.FirstRunSetup; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneFirstRunScreenImportFromStable : OsuManualInputManagerTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private readonly Mock legacyImportManager = new Mock(); + + [BackgroundDependencyLoader] + private void load() + { + legacyImportManager.Setup(m => m.GetImportCount(It.IsAny(), It.IsAny())).Returns(() => Task.FromResult(RNG.Next(0, 256))); + + Dependencies.CacheAs(legacyImportManager.Object); + } + + public TestSceneFirstRunScreenImportFromStable() + { + AddStep("load screen", () => + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new ScreenStack(new ScreenImportFromStable()) + } + }; + }); + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs index df0a69cb25..84903d381a 100644 --- a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs +++ b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs @@ -3,9 +3,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Database; using osu.Game.Online; @@ -89,6 +91,8 @@ namespace osu.Game.Beatmaps.Drawables private void queueDownloads(string[] sourceFilenames, int? limit = null) { + Debug.Assert(LoadState == LoadState.NotLoaded); + try { // Matches osu-stable, in order to provide new users with roughly the same randomised selection of bundled beatmaps. diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index b91a74c4a1..3b4200e7a9 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -22,7 +22,7 @@ namespace osu.Game.Beatmaps.Formats { public class LegacyBeatmapEncoder { - public const int LATEST_VERSION = 128; + public const int FIRST_LAZER_VERSION = 128; /// /// osu! is generally slower than taiko, so a factor is added to increase @@ -55,7 +55,7 @@ namespace osu.Game.Beatmaps.Formats public void Encode(TextWriter writer) { - writer.WriteLine($"osu file format v{LATEST_VERSION}"); + writer.WriteLine($"osu file format v{FIRST_LAZER_VERSION}"); writer.WriteLine(); handleGeneral(writer); diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs index 5845e0d4d1..700b0f5dcb 100644 --- a/osu.Game/Collections/CollectionManager.cs +++ b/osu.Game/Collections/CollectionManager.cs @@ -111,6 +111,18 @@ namespace osu.Game.Collections public Action PostNotification { protected get; set; } + public Task GetAvailableCount(StableStorage stableStorage) + { + if (!stableStorage.Exists(database_name)) + return Task.FromResult(0); + + return Task.Run(() => + { + using (var stream = stableStorage.GetStream(database_name)) + return readCollections(stream).Count; + }); + } + /// /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. /// diff --git a/osu.Game/Database/LegacyImportManager.cs b/osu.Game/Database/LegacyImportManager.cs index 59394c2952..af9db1b6ec 100644 --- a/osu.Game/Database/LegacyImportManager.cs +++ b/osu.Game/Database/LegacyImportManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; @@ -36,22 +37,66 @@ namespace osu.Game.Database [Resolved] private CollectionManager collections { get; set; } - [Resolved] + [Resolved(canBeNull: true)] private OsuGame game { get; set; } [Resolved] private IDialogOverlay dialogOverlay { get; set; } - [Resolved(CanBeNull = true)] + [Resolved(canBeNull: true)] private DesktopGameHost desktopGameHost { get; set; } private StableStorage cachedStorage; public bool SupportsImportFromStable => RuntimeInfo.IsDesktop; - public async Task ImportFromStableAsync(StableContent content) + public void UpdateStorage(string stablePath) => cachedStorage = new StableStorage(stablePath, desktopGameHost); + + public virtual async Task GetImportCount(StableContent content, CancellationToken cancellationToken) { - var stableStorage = await getStableStorage().ConfigureAwait(false); + var stableStorage = GetCurrentStableStorage(); + + if (stableStorage == null) + return 0; + + cancellationToken.ThrowIfCancellationRequested(); + + switch (content) + { + case StableContent.Beatmaps: + return await new LegacyBeatmapImporter(beatmaps).GetAvailableCount(stableStorage); + + case StableContent.Skins: + return await new LegacySkinImporter(skins).GetAvailableCount(stableStorage); + + case StableContent.Collections: + return await collections.GetAvailableCount(stableStorage); + + case StableContent.Scores: + return await new LegacyScoreImporter(scores).GetAvailableCount(stableStorage); + + default: + throw new ArgumentException($"Only one {nameof(StableContent)} flag should be specified."); + } + } + + public async Task ImportFromStableAsync(StableContent content, bool interactiveLocateIfNotFound = true) + { + var stableStorage = GetCurrentStableStorage(); + + if (stableStorage == null) + { + if (!interactiveLocateIfNotFound) + return; + + var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Schedule(() => dialogOverlay.Push(new StableDirectoryLocationDialog(taskCompletionSource))); + string stablePath = await taskCompletionSource.Task.ConfigureAwait(false); + + UpdateStorage(stablePath); + stableStorage = GetCurrentStableStorage(); + } + var importTasks = new List(); Task beatmapImportTask = Task.CompletedTask; @@ -70,20 +115,16 @@ namespace osu.Game.Database await Task.WhenAll(importTasks.ToArray()).ConfigureAwait(false); } - private async Task getStableStorage() + public StableStorage GetCurrentStableStorage() { if (cachedStorage != null) return cachedStorage; - var stableStorage = game.GetStorageForStableInstall(); + var stableStorage = game?.GetStorageForStableInstall(); if (stableStorage != null) return cachedStorage = stableStorage; - var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Schedule(() => dialogOverlay.Push(new StableDirectoryLocationDialog(taskCompletionSource))); - string stablePath = await taskCompletionSource.Task.ConfigureAwait(false); - - return cachedStorage = new StableStorage(stablePath, desktopGameHost); + return null; } } diff --git a/osu.Game/Database/LegacyModelImporter.cs b/osu.Game/Database/LegacyModelImporter.cs index d85fb5aab2..9b2a54dada 100644 --- a/osu.Game/Database/LegacyModelImporter.cs +++ b/osu.Game/Database/LegacyModelImporter.cs @@ -24,8 +24,14 @@ namespace osu.Game.Database /// /// Select paths to import from stable where all paths should be absolute. Default implementation iterates all directories in . /// - protected virtual IEnumerable GetStableImportPaths(Storage storage) => storage.GetDirectories(ImportFromStablePath) - .Select(path => storage.GetFullPath(path)); + protected virtual IEnumerable GetStableImportPaths(Storage storage) + { + if (!storage.ExistsDirectory(ImportFromStablePath)) + return Enumerable.Empty(); + + return storage.GetDirectories(ImportFromStablePath) + .Select(path => storage.GetFullPath(path)); + } protected readonly IModelImporter Importer; @@ -34,6 +40,8 @@ namespace osu.Game.Database Importer = importer; } + public Task GetAvailableCount(StableStorage stableStorage) => Task.Run(() => GetStableImportPaths(PrepareStableStorage(stableStorage)).Count()); + public Task ImportFromStableAsync(StableStorage stableStorage) { var storage = PrepareStableStorage(stableStorage); diff --git a/osu.Game/Database/LegacyScoreImporter.cs b/osu.Game/Database/LegacyScoreImporter.cs index 48445b7bdb..131b4ffb0e 100644 --- a/osu.Game/Database/LegacyScoreImporter.cs +++ b/osu.Game/Database/LegacyScoreImporter.cs @@ -15,8 +15,14 @@ namespace osu.Game.Database protected override string ImportFromStablePath => Path.Combine("Data", "r"); protected override IEnumerable GetStableImportPaths(Storage storage) - => storage.GetFiles(ImportFromStablePath).Where(p => Importer.HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)) - .Select(path => storage.GetFullPath(path)); + { + if (!storage.ExistsDirectory(ImportFromStablePath)) + return Enumerable.Empty(); + + return storage.GetFiles(ImportFromStablePath) + .Where(p => Importer.HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)) + .Select(path => storage.GetFullPath(path)); + } public LegacyScoreImporter(IModelImporter importer) : base(importer) diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs index fd64cc2056..82b9fe559f 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 @@ -25,7 +26,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 set => Component.ReadOnly = value; } - public string PlaceholderText + public LocalisableString PlaceholderText { set => Component.PlaceholderText = value; } diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs index ce2e7794a9..456bde6d1b 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -44,8 +45,8 @@ namespace osu.Game.Graphics.UserInterfaceV2 internal class Background : CompositeDrawable { - [BackgroundDependencyLoader] - private void load(OsuColour colours) + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider overlayColourProvider, OsuColour colours) { RelativeSizeAxes = Axes.Both; @@ -54,7 +55,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 InternalChild = new Box { - Colour = colours.GreySeaFoamDarker, + Colour = overlayColourProvider?.Background5 ?? colours.GreySeaFoamDarker, RelativeSizeAxes = Axes.Both, }; } diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index 9cd626af0f..1fd677034d 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -69,6 +69,26 @@ namespace osu.Game.Localisation /// public static LocalisableString SelectAll => new TranslatableString(getKey(@"select_all"), @"Select All"); + /// + /// "Beatmaps" + /// + public static LocalisableString Beatmaps => new TranslatableString(getKey(@"beatmaps"), @"Beatmaps"); + + /// + /// "Scores" + /// + public static LocalisableString Scores => new TranslatableString(getKey(@"scores"), @"Scores"); + + /// + /// "Skins" + /// + public static LocalisableString Skins => new TranslatableString(getKey(@"skins"), @"Skins"); + + /// + /// "Collections" + /// + public static LocalisableString Collections => new TranslatableString(getKey(@"collections"), @"Collections"); + private static string getKey(string key) => $@"{prefix}:{key}"; } -} +} \ No newline at end of file diff --git a/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs new file mode 100644 index 0000000000..deac7d8628 --- /dev/null +++ b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs @@ -0,0 +1,56 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class FirstRunOverlayImportFromStableScreenStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.ScreenImportFromStable"; + + /// + /// "Import" + /// + public static LocalisableString Header => new TranslatableString(getKey(@"header"), @"Import"); + + /// + /// "If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will create a copy, and not affect your existing installation." + /// + public static LocalisableString Description => new TranslatableString(getKey(@"description"), + @"If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will create a copy, and not affect your existing installation."); + + /// + /// "previous osu! install" + /// + public static LocalisableString LocateDirectoryLabel => new TranslatableString(getKey(@"locate_directory_label"), @"previous osu! install"); + + /// + /// "Click to locate a previous osu! install" + /// + public static LocalisableString LocateDirectoryPlaceholder => new TranslatableString(getKey(@"locate_directory_placeholder"), @"Click to locate a previous osu! install"); + + /// + /// "Import content from previous version" + /// + public static LocalisableString ImportButton => new TranslatableString(getKey(@"import_button"), @"Import content from previous version"); + + /// + /// "Your import will continue in the background. Check on its progress in the notifications sidebar!" + /// + public static LocalisableString ImportInProgress => + new TranslatableString(getKey(@"import_in_progress"), @"Your import will continue in the background. Check on its progress in the notifications sidebar!"); + + /// + /// "calculating..." + /// + public static LocalisableString Calculating => new TranslatableString(getKey(@"calculating"), @"calculating..."); + + /// + /// "{0} items" + /// + public static LocalisableString Items(int arg0) => new TranslatableString(getKey(@"items"), @"{0} item(s)", arg0); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Overlays/ChatOverlayV2.cs b/osu.Game/Overlays/ChatOverlayV2.cs index 6eec0bbbf4..b2c1f6858c 100644 --- a/osu.Game/Overlays/ChatOverlayV2.cs +++ b/osu.Game/Overlays/ChatOverlayV2.cs @@ -39,8 +39,11 @@ namespace osu.Game.Overlays private ChatTextBar textBar = null!; private Container currentChannelContainer = null!; - private readonly BindableFloat chatHeight = new BindableFloat(); + private readonly Dictionary loadedChannels = new Dictionary(); + protected IEnumerable DrawableChannels => loadedChannels.Values; + + private readonly BindableFloat chatHeight = new BindableFloat(); private bool isDraggingTopBar; private float dragStartChatHeight; @@ -173,7 +176,7 @@ namespace osu.Game.Overlays if (currentChannel.Value?.Id != channel.Id) { if (!channel.Joined.Value) - channel = channelManager.JoinChannel(channel); + channel = channelManager.JoinChannel(channel, false); channelManager.CurrentChannel.Value = channel; } @@ -240,38 +243,76 @@ namespace osu.Game.Overlays if (newChannel == null) { // null channel denotes that we should be showing the listing. - channelListing.State.Value = Visibility.Visible; + currentChannelContainer.Clear(false); + channelListing.Show(); textBar.ShowSearch.Value = true; } else { - channelListing.State.Value = Visibility.Hidden; + channelListing.Hide(); textBar.ShowSearch.Value = false; - loading.Show(); - LoadComponentAsync(new ChatOverlayDrawableChannel(newChannel), loaded => + if (loadedChannels.ContainsKey(newChannel)) { - currentChannelContainer.Clear(); - currentChannelContainer.Add(loaded); - loading.Hide(); - }); + currentChannelContainer.Clear(false); + currentChannelContainer.Add(loadedChannels[newChannel]); + } + else + { + loading.Show(); + + // Ensure the drawable channel is stored before async load to prevent double loading + ChatOverlayDrawableChannel drawableChannel = CreateDrawableChannel(newChannel); + loadedChannels.Add(newChannel, drawableChannel); + + LoadComponentAsync(drawableChannel, loadedDrawable => + { + // Ensure the current channel hasn't changed by the time the load completes + if (currentChannel.Value != loadedDrawable.Channel) + return; + + // Ensure the cached reference hasn't been removed from leaving the channel + if (!loadedChannels.ContainsKey(loadedDrawable.Channel)) + return; + + currentChannelContainer.Clear(false); + currentChannelContainer.Add(loadedDrawable); + loading.Hide(); + }); + } } } + protected virtual ChatOverlayDrawableChannel CreateDrawableChannel(Channel newChannel) => new ChatOverlayDrawableChannel(newChannel); + private void joinedChannelsChanged(object sender, NotifyCollectionChangedEventArgs args) { switch (args.Action) { case NotifyCollectionChangedAction.Add: IEnumerable joinedChannels = filterChannels(args.NewItems); + foreach (var channel in joinedChannels) channelList.AddChannel(channel); + break; case NotifyCollectionChangedAction.Remove: IEnumerable leftChannels = filterChannels(args.OldItems); + foreach (var channel in leftChannels) + { channelList.RemoveChannel(channel); + + if (loadedChannels.ContainsKey(channel)) + { + ChatOverlayDrawableChannel loaded = loadedChannels[channel]; + loadedChannels.Remove(channel); + // DrawableChannel removed from cache must be manually disposed + loaded.Dispose(); + } + } + break; } } diff --git a/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs b/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs index d1ea91e51a..b043f05bd8 100644 --- a/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs +++ b/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs @@ -21,6 +21,8 @@ namespace osu.Game.Overlays.FirstRunSetup protected const float CONTENT_FONT_SIZE = 16; + protected const float CONTENT_PADDING = 30; + protected const float HEADER_FONT_SIZE = 24; [Resolved] @@ -41,7 +43,7 @@ namespace osu.Game.Overlays.FirstRunSetup { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = 30 }, + Padding = new MarginPadding { Horizontal = CONTENT_PADDING }, Children = new Drawable[] { new OsuSpriteText diff --git a/osu.Game/Overlays/FirstRunSetup/ProgressRoundedButton.cs b/osu.Game/Overlays/FirstRunSetup/ProgressRoundedButton.cs new file mode 100644 index 0000000000..ee2db1f3d4 --- /dev/null +++ b/osu.Game/Overlays/FirstRunSetup/ProgressRoundedButton.cs @@ -0,0 +1,103 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK; + +namespace osu.Game.Overlays.FirstRunSetup +{ + public class ProgressRoundedButton : RoundedButton + { + public new Action? Action; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private ProgressBar progressBar = null!; + + private LoadingSpinner loading = null!; + + private SpriteIcon tick = null!; + + public ProgressRoundedButton() + { + base.Action = () => + { + loading.Show(); + Enabled.Value = false; + + Action?.Invoke(); + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddRange(new Drawable[] + { + progressBar = new ProgressBar(false) + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + FillColour = BackgroundColour, + Alpha = 0.5f, + Depth = float.MinValue + }, + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding(15), + Size = new Vector2(20), + Children = new Drawable[] + { + loading = new LoadingSpinner + { + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + }, + tick = new SpriteIcon + { + Icon = FontAwesome.Solid.Check, + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + Alpha = 0, + } + } + }, + }); + } + + public void Complete() + { + loading.Hide(); + tick.FadeIn(500, Easing.OutQuint); + + Background.FadeColour(colours.Green, 500, Easing.OutQuint); + progressBar.FillColour = colours.Green; + + this.TransformBindableTo(progressBar.Current, 1, 500, Easing.OutQuint); + } + + public void Abort() + { + loading.Hide(); + Enabled.Value = true; + this.TransformBindableTo(progressBar.Current, 0, 500, Easing.OutQuint); + } + + public void SetProgress(double progress, bool animated) + { + this.TransformBindableTo(progressBar.Current, progress, animated ? 500 : 0, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs index 66acdca8c7..17e04c0c99 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs @@ -14,8 +14,6 @@ using osu.Game.Beatmaps.Drawables; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Online; using osuTK; @@ -27,7 +25,6 @@ namespace osu.Game.Overlays.FirstRunSetup public class ScreenBeatmaps : FirstRunSetupScreen { private ProgressRoundedButton downloadBundledButton = null!; - private ProgressRoundedButton importBeatmapsButton = null!; private ProgressRoundedButton downloadTutorialButton = null!; private OsuTextFlowContainer currentlyLoadedBeatmaps = null!; @@ -43,8 +40,8 @@ namespace osu.Game.Overlays.FirstRunSetup private IDisposable? beatmapSubscription; - [BackgroundDependencyLoader(permitNulls: true)] - private void load(LegacyImportManager? legacyImportManager) + [BackgroundDependencyLoader] + private void load() { Vector2 buttonSize = new Vector2(400, 50); @@ -106,32 +103,6 @@ namespace osu.Game.Overlays.FirstRunSetup Action = downloadBundled }, new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) - { - Colour = OverlayColourProvider.Content1, - Text = "If you have an existing osu! install, you can also choose to import your existing beatmap collection.", - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - }, - importBeatmapsButton = new ProgressRoundedButton - { - Size = buttonSize, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - BackgroundColour = colours.Blue3, - Text = MaintenanceSettingsStrings.ImportBeatmapsFromStable, - Action = () => - { - importBeatmapsButton.Enabled.Value = false; - legacyImportManager?.ImportFromStableAsync(StableContent.Beatmaps).ContinueWith(t => Schedule(() => - { - if (t.IsCompletedSuccessfully) - importBeatmapsButton.Complete(); - else - importBeatmapsButton.Enabled.Value = true; - })); - } - }, - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Colour = OverlayColourProvider.Content1, Text = FirstRunSetupBeatmapScreenStrings.ObtainMoreBeatmaps, @@ -214,45 +185,5 @@ namespace osu.Game.Overlays.FirstRunSetup downloadBundledButton.SetProgress(progress, true); } } - - private class ProgressRoundedButton : RoundedButton - { - [Resolved] - private OsuColour colours { get; set; } = null!; - - private ProgressBar progressBar = null!; - - protected override void LoadComplete() - { - base.LoadComplete(); - - Add(progressBar = new ProgressBar(false) - { - RelativeSizeAxes = Axes.Both, - Blending = BlendingParameters.Additive, - FillColour = BackgroundColour, - Alpha = 0.5f, - Depth = float.MinValue - }); - } - - public void Complete() - { - Enabled.Value = false; - - Background.FadeColour(colours.Green, 500, Easing.OutQuint); - progressBar.FillColour = colours.Green; - - this.TransformBindableTo(progressBar.Current, 1, 500, Easing.OutQuint); - } - - public void SetProgress(double progress, bool animated) - { - if (!Enabled.Value) - return; - - this.TransformBindableTo(progressBar.Current, progress, animated ? 500 : 0, Easing.OutQuint); - } - } } } diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs new file mode 100644 index 0000000000..62b517d982 --- /dev/null +++ b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs @@ -0,0 +1,267 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Overlays.Settings; +using osu.Game.Screens.Edit.Setup; +using osuTK; + +namespace osu.Game.Overlays.FirstRunSetup +{ + [LocalisableDescription(typeof(FirstRunOverlayImportFromStableScreenStrings), nameof(FirstRunOverlayImportFromStableScreenStrings.Header))] + public class ScreenImportFromStable : FirstRunSetupScreen + { + private static readonly Vector2 button_size = new Vector2(400, 50); + + private ProgressRoundedButton importButton = null!; + + private OsuTextFlowContainer progressText = null!; + + [Resolved] + private LegacyImportManager legacyImportManager { get; set; } = null!; + + private StableLocatorLabelledTextBox stableLocatorTextBox = null!; + + private IEnumerable contentCheckboxes => Content.Children.OfType(); + + [BackgroundDependencyLoader(permitNulls: true)] + private void load() + { + Content.Children = new Drawable[] + { + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + { + Colour = OverlayColourProvider.Content1, + Text = FirstRunOverlayImportFromStableScreenStrings.Description, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }, + stableLocatorTextBox = new StableLocatorLabelledTextBox + { + Label = FirstRunOverlayImportFromStableScreenStrings.LocateDirectoryLabel, + PlaceholderText = FirstRunOverlayImportFromStableScreenStrings.LocateDirectoryPlaceholder + }, + new ImportCheckbox(CommonStrings.Beatmaps, StableContent.Beatmaps), + new ImportCheckbox(CommonStrings.Scores, StableContent.Scores), + new ImportCheckbox(CommonStrings.Skins, StableContent.Skins), + new ImportCheckbox(CommonStrings.Collections, StableContent.Collections), + importButton = new ProgressRoundedButton + { + Size = button_size, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = FirstRunOverlayImportFromStableScreenStrings.ImportButton, + Action = runImport + }, + progressText = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + { + Colour = OverlayColourProvider.Content1, + Text = FirstRunOverlayImportFromStableScreenStrings.ImportInProgress, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Alpha = 0, + }, + }; + + stableLocatorTextBox.Current.BindValueChanged(_ => updateStablePath(), true); + } + + private void updateStablePath() + { + var storage = legacyImportManager.GetCurrentStableStorage(); + + if (storage == null) + { + toggleInteraction(false); + + stableLocatorTextBox.Current.Disabled = false; + stableLocatorTextBox.Current.Value = string.Empty; + return; + } + + foreach (var c in contentCheckboxes) + { + c.Current.Disabled = false; + c.UpdateCount(); + } + + toggleInteraction(true); + stableLocatorTextBox.Current.Value = storage.GetFullPath(string.Empty); + importButton.Enabled.Value = true; + } + + private void runImport() + { + toggleInteraction(false); + progressText.FadeIn(1000, Easing.OutQuint); + + StableContent importableContent = 0; + + foreach (var c in contentCheckboxes.Where(c => c.Current.Value)) + importableContent |= c.StableContent; + + legacyImportManager.ImportFromStableAsync(importableContent, false).ContinueWith(t => Schedule(() => + { + progressText.FadeOut(500, Easing.OutQuint); + + if (t.IsCompletedSuccessfully) + importButton.Complete(); + else + { + toggleInteraction(true); + importButton.Abort(); + } + })); + } + + private void toggleInteraction(bool allow) + { + importButton.Enabled.Value = allow; + stableLocatorTextBox.Current.Disabled = !allow; + foreach (var c in contentCheckboxes) + c.Current.Disabled = !allow; + } + + private class ImportCheckbox : SettingsCheckbox + { + public readonly StableContent StableContent; + + private readonly LocalisableString title; + + [Resolved] + private LegacyImportManager legacyImportManager { get; set; } = null!; + + private CancellationTokenSource? countUpdateCancellation; + + public ImportCheckbox(LocalisableString title, StableContent stableContent) + { + this.title = title; + + StableContent = stableContent; + + Current.Default = true; + Current.Value = true; + + LabelText = title; + } + + public void UpdateCount() + { + LabelText = LocalisableString.Interpolate($"{title} ({FirstRunOverlayImportFromStableScreenStrings.Calculating})"); + + countUpdateCancellation?.Cancel(); + countUpdateCancellation = new CancellationTokenSource(); + + legacyImportManager.GetImportCount(StableContent, countUpdateCancellation.Token).ContinueWith(task => Schedule(() => + { + if (task.IsCanceled) + return; + + int count = task.GetResultSafely(); + + LabelText = LocalisableString.Interpolate($"{title} ({FirstRunOverlayImportFromStableScreenStrings.Items(count)})"); + })); + } + } + + internal class StableLocatorLabelledTextBox : LabelledTextBoxWithPopover, ICanAcceptFiles + { + [Resolved] + private LegacyImportManager legacyImportManager { get; set; } = null!; + + public IEnumerable HandledExtensions { get; } = new[] { string.Empty }; + + private readonly Bindable currentDirectory = new Bindable(); + + [Resolved(canBeNull: true)] // Can't really be null but required to handle potential of disposal before DI completes. + private OsuGameBase? game { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + game?.RegisterImportHandler(this); + + currentDirectory.BindValueChanged(onDirectorySelected); + + string? fullPath = legacyImportManager.GetCurrentStableStorage()?.GetFullPath(string.Empty); + if (fullPath != null) + currentDirectory.Value = new DirectoryInfo(fullPath); + } + + private void onDirectorySelected(ValueChangedEvent directory) + { + if (directory.NewValue == null) + { + Current.Value = string.Empty; + return; + } + + // DirectorySelectors can trigger a noop value changed, but `DirectoryInfo` equality doesn't catch this. + if (directory.OldValue?.FullName == directory.NewValue.FullName) + return; + + if (directory.NewValue?.GetFiles(@"osu!.*.cfg").Any() ?? false) + { + this.HidePopover(); + + string path = directory.NewValue.FullName; + + legacyImportManager.UpdateStorage(path); + Current.Value = path; + } + } + + Task ICanAcceptFiles.Import(params string[] paths) + { + Schedule(() => currentDirectory.Value = new DirectoryInfo(paths.First())); + return Task.CompletedTask; + } + + Task ICanAcceptFiles.Import(params ImportTask[] tasks) => throw new NotImplementedException(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + game?.UnregisterImportHandler(this); + } + + public override Popover GetPopover() => new DirectoryChooserPopover(currentDirectory); + + private class DirectoryChooserPopover : OsuPopover + { + public DirectoryChooserPopover(Bindable currentDirectory) + { + Child = new Container + { + Size = new Vector2(600, 400), + Child = new OsuDirectorySelector(currentDirectory.Value?.FullName) + { + RelativeSizeAxes = Axes.Both, + CurrentPath = { BindTarget = currentDirectory } + }, + }; + } + } + } + } +} diff --git a/osu.Game/Overlays/FirstRunSetupOverlay.cs b/osu.Game/Overlays/FirstRunSetupOverlay.cs index cebb2f5e3b..7b0de4affe 100644 --- a/osu.Game/Overlays/FirstRunSetupOverlay.cs +++ b/osu.Game/Overlays/FirstRunSetupOverlay.cs @@ -4,12 +4,14 @@ #nullable enable using System; +using System.Collections.Generic; using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; @@ -17,6 +19,7 @@ using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; @@ -55,18 +58,10 @@ namespace osu.Game.Overlays /// public FirstRunSetupScreen? CurrentScreen => (FirstRunSetupScreen?)stack?.CurrentScreen; - private readonly Type[] steps = - { - typeof(ScreenWelcome), - typeof(ScreenBeatmaps), - typeof(ScreenUIScale), - typeof(ScreenBehaviour), - }; + private readonly List steps = new List(); private Container screenContent = null!; - private Bindable? overlayActivationMode; - private Container content = null!; private LoadingSpinner loading = null!; @@ -77,15 +72,22 @@ namespace osu.Game.Overlays { } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + [BackgroundDependencyLoader(permitNulls: true)] + private void load(OsuColour colours, LegacyImportManager? legacyImportManager) { + steps.Add(typeof(ScreenWelcome)); + steps.Add(typeof(ScreenBeatmaps)); + if (legacyImportManager?.SupportsImportFromStable == true) + steps.Add(typeof(ScreenImportFromStable)); + steps.Add(typeof(ScreenUIScale)); + steps.Add(typeof(ScreenBehaviour)); + Header.Title = FirstRunSetupOverlayStrings.FirstRunSetupTitle; Header.Description = FirstRunSetupOverlayStrings.FirstRunSetupDescription; MainAreaContent.AddRange(new Drawable[] { - content = new Container + content = new PopoverContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -221,16 +223,9 @@ namespace osu.Game.Overlays // if we are valid for display, only do so after reaching the main menu. performer.PerformFromScreen(screen => { - MainMenu menu = (MainMenu)screen; - - // Eventually I'd like to replace this with a better method that doesn't access the screen. - // Either this dialog would be converted to its own screen, or at very least be "hosted" by a screen pushed to the main menu. - // Alternatively, another method of disabling notifications could be added to `INotificationOverlay`. - if (menu != null) - { - overlayActivationMode = menu.OverlayActivationMode.GetBoundCopy(); - overlayActivationMode.Value = OverlayActivation.UserTriggered; - } + // Hides the toolbar for us. + if (screen is MainMenu menu) + menu.ReturnToOsuLogo(); base.Show(); }, new[] { typeof(MainMenu) }); @@ -253,13 +248,6 @@ namespace osu.Game.Overlays content.ScaleTo(0.99f, 400, Easing.OutQuint); - if (overlayActivationMode != null) - { - // If this is non-null we are guaranteed to have come from the main menu. - overlayActivationMode.Value = OverlayActivation.All; - overlayActivationMode = null; - } - if (currentStepIndex != null) { notificationOverlay.Post(new SimpleNotification @@ -313,7 +301,7 @@ namespace osu.Game.Overlays currentStepIndex++; - if (currentStepIndex < steps.Length) + if (currentStepIndex < steps.Count) { var nextScreen = (Screen)Activator.CreateInstance(steps[currentStepIndex.Value]); @@ -345,7 +333,7 @@ namespace osu.Game.Overlays return; bool isFirstStep = currentStepIndex == 0; - bool isLastStep = currentStepIndex == steps.Length - 1; + bool isLastStep = currentStepIndex == steps.Count - 1; if (isFirstStep) { diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 1dad185b78..f1a998bd3c 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -428,9 +428,8 @@ namespace osu.Game.Overlays.Mods base.PopIn(); multiplierDisplay? - .Delay(fade_in_duration * 0.65f) - .FadeIn(fade_in_duration / 2, Easing.OutQuint) - .ScaleTo(1, fade_in_duration, Easing.OutElastic); + .FadeIn(fade_in_duration, Easing.OutQuint) + .MoveToY(0, fade_in_duration, Easing.OutQuint); int nonFilteredColumnCount = 0; @@ -465,7 +464,7 @@ namespace osu.Game.Overlays.Mods multiplierDisplay? .FadeOut(fade_out_duration / 2, Easing.OutQuint) - .ScaleTo(0.75f, fade_out_duration, Easing.OutQuint); + .MoveToY(-distance, fade_out_duration / 2, Easing.OutQuint); int nonFilteredColumnCount = 0; diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 216510fcf3..f8d796a778 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -7,6 +7,7 @@ using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; @@ -361,20 +362,23 @@ namespace osu.Game.Rulesets.Edit /// The most relevant . protected virtual Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => drawableRulesetWrapper.Playfield; - public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) + public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) { var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); double? targetTime = null; - if (playfield is ScrollingPlayfield scrollingPlayfield) + if (snapType.HasFlagFast(SnapType.Grids)) { - targetTime = scrollingPlayfield.TimeAtScreenSpacePosition(screenSpacePosition); + if (playfield is ScrollingPlayfield scrollingPlayfield) + { + targetTime = scrollingPlayfield.TimeAtScreenSpacePosition(screenSpacePosition); - // apply beat snapping - targetTime = BeatSnapProvider.SnapTime(targetTime.Value); + // apply beat snapping + targetTime = BeatSnapProvider.SnapTime(targetTime.Value); - // convert back to screen space - screenSpacePosition = scrollingPlayfield.ScreenSpacePositionAtTime(targetTime.Value); + // convert back to screen space + screenSpacePosition = scrollingPlayfield.ScreenSpacePositionAtTime(targetTime.Value); + } } return new SnapResult(screenSpacePosition, targetTime, playfield); @@ -414,10 +418,7 @@ namespace osu.Game.Rulesets.Edit #region IPositionSnapProvider - public abstract SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition); - - public virtual SnapResult FindSnappedPosition(Vector2 screenSpacePosition) => - new SnapResult(screenSpacePosition, null); + public abstract SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All); #endregion } diff --git a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs index 837b04424a..a6a6e39e23 100644 --- a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs @@ -8,7 +8,6 @@ namespace osu.Game.Rulesets.Edit { /// /// A snap provider which given a proposed position for a hit object, potentially offers a more correct position and time value inferred from the context of the beatmap. - /// Provided values are inferred in an isolated context, without consideration of other nearby hit objects. /// [Cached] public interface IPositionSnapProvider @@ -16,18 +15,9 @@ namespace osu.Game.Rulesets.Edit /// /// Given a position, find a valid time and position snap. /// - /// - /// This call should be equivalent to running with any additional logic that can be performed without the time immutability restriction. - /// /// The screen-space position to be snapped. + /// The type of snapping to apply. /// The time and position post-snapping. - SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition); - - /// - /// Given a position, find a valid position snap, without changing the time value. - /// - /// The screen-space position to be snapped. - /// The position post-snapping. Time will always be null. - SnapResult FindSnappedPosition(Vector2 screenSpacePosition); + SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All); } } diff --git a/osu.Game/Rulesets/Edit/SnapType.cs b/osu.Game/Rulesets/Edit/SnapType.cs new file mode 100644 index 0000000000..6761356331 --- /dev/null +++ b/osu.Game/Rulesets/Edit/SnapType.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Rulesets.Edit +{ + [Flags] + public enum SnapType + { + None = 0, + NearbyObjects = 1 << 0, + Grids = 1 << 1, + All = NearbyObjects | Grids, + } +} diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 2a7f2b037f..7cf68a2df7 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -336,10 +336,14 @@ namespace osu.Game.Rulesets.Objects.Legacy while (++endIndex < vertices.Length - endPointLength) { - // Keep incrementing while an implicit segment doesn't need to be started + // Keep incrementing while an implicit segment doesn't need to be started. if (vertices[endIndex].Position != vertices[endIndex - 1].Position) continue; + // Adjacent legacy Catmull segments should be treated as a single segment. + if (FormatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION && type == PathType.Catmull) + continue; + // The last control point of each segment is not allowed to start a new implicit segment. if (endIndex == vertices.Length - endPointLength - 1) continue; diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs index 2b75f93f9e..782255733f 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.UI.Scrolling public new ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)base.HitObjectContainer; [Resolved] - protected IScrollingInfo ScrollingInfo { get; private set; } + public IScrollingInfo ScrollingInfo { get; private set; } [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 186c66e0af..d56dc176f6 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -486,7 +486,7 @@ namespace osu.Game.Screens.Edit.Compose.Components Vector2 originalPosition = movementBlueprintOriginalPositions[i]; var testPosition = originalPosition + distanceTravelled; - var positionalResult = snapProvider.FindSnappedPosition(testPosition); + var positionalResult = snapProvider.FindSnappedPositionAndTime(testPosition, SnapType.NearbyObjects); if (positionalResult.ScreenSpacePosition == testPosition) continue; @@ -505,7 +505,7 @@ namespace osu.Game.Screens.Edit.Compose.Components Vector2 movePosition = movementBlueprintOriginalPositions.First() + distanceTravelled; // Retrieve a snapped position. - var result = snapProvider?.FindSnappedPositionAndTime(movePosition); + var result = snapProvider?.FindSnappedPositionAndTime(movePosition, ~SnapType.NearbyObjects); if (result == null) { diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index ab21a83c43..9abea73f6b 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -154,7 +154,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateBankPlaceholderText(IEnumerable objects) { string? commonBank = getCommonBank(objects.Select(h => h.SampleControlPoint).ToArray()); - bank.PlaceholderText = string.IsNullOrEmpty(commonBank) ? "(multiple)" : null; + bank.PlaceholderText = string.IsNullOrEmpty(commonBank) ? "(multiple)" : string.Empty; } private void updateVolumeFor(IEnumerable objects, int? newVolume) diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index 7e66c57917..992ab7947e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -303,10 +303,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// public double VisibleRange => track.Length / Zoom; - public SnapResult FindSnappedPosition(Vector2 screenSpacePosition) => - new SnapResult(screenSpacePosition, null); - - public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition) => + public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) => new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(getTimeFromPosition(Content.ToLocalSpace(screenSpacePosition)))); private double getTimeFromPosition(Vector2 localPosition) => diff --git a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs index d1e35ae20d..aae19396db 100644 --- a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs +++ b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs @@ -11,11 +11,8 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; using osu.Game.Database; -using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osuTK; @@ -24,7 +21,7 @@ namespace osu.Game.Screens.Edit.Setup /// /// A labelled textbox which reveals an inline file chooser when clicked. /// - internal class FileChooserLabelledTextBox : LabelledTextBox, ICanAcceptFiles, IHasPopover + internal class FileChooserLabelledTextBox : LabelledTextBoxWithPopover, ICanAcceptFiles { private readonly string[] handledExtensions; @@ -40,16 +37,6 @@ namespace osu.Game.Screens.Edit.Setup this.handledExtensions = handledExtensions; } - protected override OsuTextBox CreateTextBox() => - new FileChooserOsuTextBox - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - CornerRadius = CORNER_RADIUS, - OnFocused = this.ShowPopover - }; - protected override void LoadComplete() { base.LoadComplete(); @@ -81,27 +68,7 @@ namespace osu.Game.Screens.Edit.Setup game.UnregisterImportHandler(this); } - internal class FileChooserOsuTextBox : OsuTextBox - { - public Action OnFocused; - - protected override bool OnDragStart(DragStartEvent e) - { - // This text box is intended to be "read only" without actually specifying that. - // As such we don't want to allow the user to select its content with a drag. - return false; - } - - protected override void OnFocus(FocusEvent e) - { - OnFocused?.Invoke(); - base.OnFocus(e); - - GetContainingInputManager().TriggerFocusContention(this); - } - } - - public Popover GetPopover() => new FileChooserPopover(handledExtensions, currentFile); + public override Popover GetPopover() => new FileChooserPopover(handledExtensions, currentFile); private class FileChooserPopover : OsuPopover { diff --git a/osu.Game/Screens/Edit/Setup/LabelledTextBoxWithPopover.cs b/osu.Game/Screens/Edit/Setup/LabelledTextBoxWithPopover.cs new file mode 100644 index 0000000000..799311dd2d --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/LabelledTextBoxWithPopover.cs @@ -0,0 +1,52 @@ +// 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.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Screens.Edit.Setup +{ + internal abstract class LabelledTextBoxWithPopover : LabelledTextBox, IHasPopover + { + public abstract Popover GetPopover(); + + protected override OsuTextBox CreateTextBox() => + new PopoverTextBox + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + CornerRadius = CORNER_RADIUS, + OnFocused = this.ShowPopover + }; + + internal class PopoverTextBox : OsuTextBox + { + public Action OnFocused; + + protected override bool OnDragStart(DragStartEvent e) + { + // This text box is intended to be "read only" without actually specifying that. + // As such we don't want to allow the user to select its content with a drag. + return false; + } + + protected override void OnFocus(FocusEvent e) + { + if (Current.Disabled) + return; + + OnFocused?.Invoke(); + base.OnFocus(e); + + GetContainingInputManager().TriggerFocusContention(this); + } + } + } +} diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 4401ee93ec..6fc8039413 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -150,6 +150,8 @@ namespace osu.Game.Screens.Menu [Resolved(canBeNull: true)] private IPerformFromScreenRunner performer { get; set; } + public void ReturnToOsuLogo() => Buttons.State = ButtonSystemState.Initial; + private void confirmAndExit() { if (exitConfirmed) return; diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index 5390c666ed..137bf7e0aa 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -8,6 +8,7 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Net; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Logging; @@ -160,6 +161,8 @@ namespace osu.Game.Utils }; scope.SetTag(@"ruleset", ruleset.ShortName); + scope.SetTag(@"os", $"{RuntimeInfo.OS} ({Environment.OSVersion})"); + scope.SetTag(@"processor count", Environment.ProcessorCount.ToString()); }); } else diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 53a4753223..17bc056b59 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -34,7 +34,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/osu.iOS.props b/osu.iOS.props index 2e861faa1f..191d1bd5ca 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -89,6 +89,6 @@ - + diff --git a/osu.iOS/Info.plist b/osu.iOS/Info.plist index 02968b87a7..16cb68fa7d 100644 --- a/osu.iOS/Info.plist +++ b/osu.iOS/Info.plist @@ -33,6 +33,8 @@ UIStatusBarHidden + UIApplicationSupportsIndirectInputEvents + CADisableMinimumFrameDurationOnPhone NSCameraUsageDescription