diff --git a/osu.Android.props b/osu.Android.props
index d64855e5c1..8ebfde8047 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -10,7 +10,7 @@
true
-
+
+
+
\ No newline at end of file
diff --git a/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml
index bf7c0bfeca..52b34959b9 100644
--- a/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml
+++ b/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml
index 4a1545a423..f5a49210ea 100644
--- a/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml
+++ b/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml
@@ -1,5 +1,5 @@
-
+
\ No newline at end of file
diff --git a/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml
index 45d27dda70..ed4725dd94 100644
--- a/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml
+++ b/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml
@@ -1,5 +1,5 @@
-
+
\ No newline at end of file
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs
index 0c064ecfa6..9338d5453d 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs
@@ -9,6 +9,7 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
@@ -70,12 +71,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
base.Content.Children = new Drawable[]
{
editorClock = new EditorClock(editorBeatmap),
- snapProvider,
+ new PopoverContainer { Child = snapProvider },
Content
};
}
- protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
+ protected override Container Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both };
[SetUp]
public void Setup() => Schedule(() =>
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs
new file mode 100644
index 0000000000..d7dd30d608
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs
@@ -0,0 +1,95 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Edit;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Screens.Edit.Components.RadioButtons;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor
+{
+ public partial class TestScenePreciseRotation : TestSceneOsuEditor
+ {
+ protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset);
+
+ [Test]
+ public void TestHotkeyHandling()
+ {
+ AddStep("select single circle", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType().First()));
+ AddStep("press rotate hotkey", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.Key(Key.R);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ });
+ AddUntilStep("no popover present", () => this.ChildrenOfType().Count(), () => Is.Zero);
+
+ AddStep("select first three objects", () =>
+ {
+ EditorBeatmap.SelectedHitObjects.Clear();
+ EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects.Take(3));
+ });
+ AddStep("press rotate hotkey", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.Key(Key.R);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ });
+ AddUntilStep("popover present", () => this.ChildrenOfType().Count(), () => Is.EqualTo(1));
+ AddStep("press rotate hotkey", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.Key(Key.R);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ });
+ AddUntilStep("no popover present", () => this.ChildrenOfType().Count(), () => Is.Zero);
+ }
+
+ [Test]
+ public void TestRotateCorrectness()
+ {
+ AddStep("replace objects", () =>
+ {
+ EditorBeatmap.Clear();
+ EditorBeatmap.AddRange(new HitObject[]
+ {
+ new HitCircle { Position = new Vector2(100) },
+ new HitCircle { Position = new Vector2(200) },
+ });
+ });
+ AddStep("select both circles", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects));
+ AddStep("press rotate hotkey", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.Key(Key.R);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ });
+ AddUntilStep("popover present", getPopover, () => Is.Not.Null);
+
+ AddStep("rotate by 180deg", () => getPopover().ChildrenOfType().Single().Current.Value = "180");
+ AddAssert("first object rotated 180deg around playfield centre",
+ () => EditorBeatmap.HitObjects.OfType().ElementAt(0).Position,
+ () => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(100)));
+ AddAssert("second object rotated 180deg around playfield centre",
+ () => EditorBeatmap.HitObjects.OfType().ElementAt(1).Position,
+ () => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(200)));
+
+ AddStep("change rotation origin", () => getPopover().ChildrenOfType().ElementAt(1).TriggerClick());
+ AddAssert("first object rotated 90deg around selection centre",
+ () => EditorBeatmap.HitObjects.OfType().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200)));
+ AddAssert("second object rotated 90deg around selection centre",
+ () => EditorBeatmap.HitObjects.OfType().ElementAt(1).Position, () => Is.EqualTo(new Vector2(100, 100)));
+
+ PreciseRotationPopover? getPopover() => this.ChildrenOfType().SingleOrDefault();
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderReversal.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderReversal.cs
new file mode 100644
index 0000000000..9c5eb83e3c
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderReversal.cs
@@ -0,0 +1,110 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Utils;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor
+{
+ public partial class TestSceneSliderReversal : TestSceneOsuEditor
+ {
+ protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(Ruleset.Value, false);
+
+ private readonly PathControlPoint[][] paths =
+ {
+ createPathSegment(
+ PathType.PerfectCurve,
+ new Vector2(200, -50),
+ new Vector2(250, 0)
+ ),
+ createPathSegment(
+ PathType.Linear,
+ new Vector2(100, 0),
+ new Vector2(100, 100)
+ )
+ };
+
+ private static PathControlPoint[] createPathSegment(PathType type, params Vector2[] positions)
+ {
+ return positions.Select(p => new PathControlPoint
+ {
+ Position = p
+ }).Prepend(new PathControlPoint
+ {
+ Type = type
+ }).ToArray();
+ }
+
+ private Slider selectedSlider => (Slider)EditorBeatmap.SelectedHitObjects[0];
+
+ [TestCase(0, 250)]
+ [TestCase(0, 200)]
+ [TestCase(1, 120)]
+ [TestCase(1, 80)]
+ public void TestSliderReversal(int pathIndex, double length)
+ {
+ var controlPoints = paths[pathIndex];
+
+ Vector2 oldStartPos = default;
+ Vector2 oldEndPos = default;
+ double oldDistance = default;
+ var oldControlPointTypes = controlPoints.Select(p => p.Type);
+
+ AddStep("Add slider", () =>
+ {
+ var slider = new Slider
+ {
+ Position = new Vector2(OsuPlayfield.BASE_SIZE.X / 2, OsuPlayfield.BASE_SIZE.Y / 2),
+ Path = new SliderPath(controlPoints)
+ {
+ ExpectedDistance = { Value = length }
+ }
+ };
+
+ EditorBeatmap.Add(slider);
+
+ oldStartPos = slider.Position;
+ oldEndPos = slider.EndPosition;
+ oldDistance = slider.Path.Distance;
+ });
+
+ AddStep("Select slider", () =>
+ {
+ var slider = (Slider)EditorBeatmap.HitObjects[0];
+ EditorBeatmap.SelectedHitObjects.Add(slider);
+ });
+
+ AddStep("Reverse slider", () =>
+ {
+ InputManager.PressKey(Key.LControl);
+ InputManager.Key(Key.G);
+ InputManager.ReleaseKey(Key.LControl);
+ });
+
+ AddAssert("Slider has correct length", () =>
+ Precision.AlmostEquals(selectedSlider.Path.Distance, oldDistance));
+
+ AddAssert("Slider has correct start position", () =>
+ Vector2.Distance(selectedSlider.Position, oldEndPos) < 1);
+
+ AddAssert("Slider has correct end position", () =>
+ Vector2.Distance(selectedSlider.EndPosition, oldStartPos) < 1);
+
+ AddAssert("Control points have correct types", () =>
+ {
+ var newControlPointTypes = selectedSlider.Path.ControlPoints.Select(p => p.Type).ToArray();
+
+ return oldControlPointTypes.Take(newControlPointTypes.Length).SequenceEqual(newControlPointTypes);
+ });
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
index 0b80750a02..cff2171cbd 100644
--- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
+++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
@@ -85,6 +85,11 @@ namespace osu.Game.Rulesets.Osu.Edit
// we may be entering the screen with a selection already active
updateDistanceSnapGrid();
+
+ RightToolbox.Add(new TransformToolboxGroup
+ {
+ RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler
+ });
}
protected override ComposeBlueprintContainer CreateBlueprintContainer()
diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
new file mode 100644
index 0000000000..f09d6b78e6
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
@@ -0,0 +1,107 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Graphics.UserInterfaceV2;
+using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Screens.Edit.Components.RadioButtons;
+using osu.Game.Screens.Edit.Compose.Components;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Edit
+{
+ public partial class PreciseRotationPopover : OsuPopover
+ {
+ private readonly SelectionRotationHandler rotationHandler;
+
+ private readonly Bindable rotationInfo = new Bindable(new PreciseRotationInfo(0, RotationOrigin.PlayfieldCentre));
+
+ private SliderWithTextBoxInput angleInput = null!;
+ private EditorRadioButtonCollection rotationOrigin = null!;
+
+ public PreciseRotationPopover(SelectionRotationHandler rotationHandler)
+ {
+ this.rotationHandler = rotationHandler;
+
+ AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Child = new FillFlowContainer
+ {
+ Width = 220,
+ AutoSizeAxes = Axes.Y,
+ Spacing = new Vector2(20),
+ Children = new Drawable[]
+ {
+ angleInput = new SliderWithTextBoxInput("Angle (degrees):")
+ {
+ Current = new BindableNumber
+ {
+ MinValue = -360,
+ MaxValue = 360,
+ Precision = 1
+ },
+ Instantaneous = true
+ },
+ rotationOrigin = new EditorRadioButtonCollection
+ {
+ RelativeSizeAxes = Axes.X,
+ Items = new[]
+ {
+ new RadioButton("Playfield centre",
+ () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre },
+ () => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
+ new RadioButton("Selection centre",
+ () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.SelectionCentre },
+ () => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare })
+ }
+ }
+ }
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ ScheduleAfterChildren(() => angleInput.TakeFocus());
+ angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue });
+ rotationOrigin.Items.First().Select();
+
+ rotationInfo.BindValueChanged(rotation =>
+ {
+ rotationHandler.Update(rotation.NewValue.Degrees, rotation.NewValue.Origin == RotationOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null);
+ });
+ }
+
+ protected override void PopIn()
+ {
+ base.PopIn();
+ rotationHandler.Begin();
+ }
+
+ protected override void PopOut()
+ {
+ base.PopOut();
+
+ if (IsLoaded)
+ rotationHandler.Commit();
+ }
+ }
+
+ public enum RotationOrigin
+ {
+ PlayfieldCentre,
+ SelectionCentre
+ }
+
+ public record PreciseRotationInfo(float Degrees, RotationOrigin Origin);
+}
diff --git a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs
new file mode 100644
index 0000000000..3da9f5b69b
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs
@@ -0,0 +1,80 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Input.Bindings;
+using osu.Framework.Input.Events;
+using osu.Game.Input.Bindings;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Screens.Edit.Components;
+using osu.Game.Screens.Edit.Compose.Components;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Edit
+{
+ public partial class TransformToolboxGroup : EditorToolboxGroup, IKeyBindingHandler
+ {
+ private readonly Bindable canRotate = new BindableBool();
+
+ private EditorToolButton rotateButton = null!;
+
+ public SelectionRotationHandler RotationHandler { get; init; } = null!;
+
+ public TransformToolboxGroup()
+ : base("transform")
+ {
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Child = new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Spacing = new Vector2(5),
+ Children = new Drawable[]
+ {
+ rotateButton = new EditorToolButton("Rotate",
+ () => new SpriteIcon { Icon = FontAwesome.Solid.Undo },
+ () => new PreciseRotationPopover(RotationHandler)),
+ // TODO: scale
+ }
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ // bindings to `Enabled` on the buttons are decoupled on purpose
+ // due to the weird `OsuButton` behaviour of resetting `Enabled` to `false` when `Action` is set.
+ canRotate.BindTo(RotationHandler.CanRotate);
+ canRotate.BindValueChanged(_ => rotateButton.Enabled.Value = canRotate.Value, true);
+ }
+
+ public bool OnPressed(KeyBindingPressEvent e)
+ {
+ if (e.Repeat) return false;
+
+ switch (e.Action)
+ {
+ case GlobalAction.EditorToggleRotateControl:
+ {
+ rotateButton.TriggerClick();
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public void OnReleased(KeyBindingReleaseEvent e)
+ {
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs
index 9d64c354e2..d818c8baee 100644
--- a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs
@@ -257,7 +257,7 @@ namespace osu.Game.Rulesets.Osu.Skinning
texture.Bind();
for (int i = 0; i < points.Count; i++)
- drawPointQuad(points[i], textureRect, i + firstVisiblePointIndex);
+ drawPointQuad(renderer, points[i], textureRect, i + firstVisiblePointIndex);
UnbindTextureShader(renderer);
renderer.PopLocalMatrix();
@@ -325,7 +325,7 @@ namespace osu.Game.Rulesets.Osu.Skinning
private float getRotation(int index) => max_rotation * (StatelessRNG.NextSingle(rotationSeed, index) * 2 - 1);
- private void drawPointQuad(SmokePoint point, RectangleF textureRect, int index)
+ private void drawPointQuad(IRenderer renderer, SmokePoint point, RectangleF textureRect, int index)
{
Debug.Assert(quadBatch != null);
@@ -347,25 +347,25 @@ namespace osu.Game.Rulesets.Osu.Skinning
var localBotLeft = point.Position + ortho - dir;
var localBotRight = point.Position + ortho + dir;
- quadBatch.Add(new TexturedVertex2D
+ quadBatch.Add(new TexturedVertex2D(renderer)
{
Position = localTopLeft,
TexturePosition = textureRect.TopLeft,
Colour = Color4Extensions.Multiply(ColourAtPosition(localTopLeft), colour),
});
- quadBatch.Add(new TexturedVertex2D
+ quadBatch.Add(new TexturedVertex2D(renderer)
{
Position = localTopRight,
TexturePosition = textureRect.TopRight,
Colour = Color4Extensions.Multiply(ColourAtPosition(localTopRight), colour),
});
- quadBatch.Add(new TexturedVertex2D
+ quadBatch.Add(new TexturedVertex2D(renderer)
{
Position = localBotRight,
TexturePosition = textureRect.BottomRight,
Colour = Color4Extensions.Multiply(ColourAtPosition(localBotRight), colour),
});
- quadBatch.Add(new TexturedVertex2D
+ quadBatch.Add(new TexturedVertex2D(renderer)
{
Position = localBotLeft,
TexturePosition = textureRect.BottomLeft,
diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs
index a29faac5a0..0774d34488 100644
--- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs
+++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs
@@ -286,7 +286,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
if (time - part.Time >= 1)
continue;
- vertexBatch.Add(new TexturedTrailVertex
+ vertexBatch.Add(new TexturedTrailVertex(renderer)
{
Position = new Vector2(part.Position.X - size.X * originPosition.X, part.Position.Y + size.Y * (1 - originPosition.Y)),
TexturePosition = textureRect.BottomLeft,
@@ -295,7 +295,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
Time = part.Time
});
- vertexBatch.Add(new TexturedTrailVertex
+ vertexBatch.Add(new TexturedTrailVertex(renderer)
{
Position = new Vector2(part.Position.X + size.X * (1 - originPosition.X), part.Position.Y + size.Y * (1 - originPosition.Y)),
TexturePosition = textureRect.BottomRight,
@@ -304,7 +304,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
Time = part.Time
});
- vertexBatch.Add(new TexturedTrailVertex
+ vertexBatch.Add(new TexturedTrailVertex(renderer)
{
Position = new Vector2(part.Position.X + size.X * (1 - originPosition.X), part.Position.Y - size.Y * originPosition.Y),
TexturePosition = textureRect.TopRight,
@@ -313,7 +313,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
Time = part.Time
});
- vertexBatch.Add(new TexturedTrailVertex
+ vertexBatch.Add(new TexturedTrailVertex(renderer)
{
Position = new Vector2(part.Position.X - size.X * originPosition.X, part.Position.Y - size.Y * originPosition.Y),
TexturePosition = textureRect.TopLeft,
@@ -362,12 +362,22 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
[VertexMember(1, VertexAttribPointerType.Float)]
public float Time;
+ [VertexMember(1, VertexAttribPointerType.Int)]
+ private readonly int maskingIndex;
+
+ public TexturedTrailVertex(IRenderer renderer)
+ {
+ this = default;
+ maskingIndex = renderer.CurrentMaskingIndex;
+ }
+
public bool Equals(TexturedTrailVertex other)
{
return Position.Equals(other.Position)
&& TexturePosition.Equals(other.TexturePosition)
&& Colour.Equals(other.Colour)
- && Time.Equals(other.Time);
+ && Time.Equals(other.Time)
+ && maskingIndex == other.maskingIndex;
}
}
}
diff --git a/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml
index 452b9683ec..cc88d3080a 100644
--- a/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml
+++ b/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml
@@ -1,5 +1,5 @@
-
+
\ No newline at end of file
diff --git a/osu.Game.Tests.Android/AndroidManifest.xml b/osu.Game.Tests.Android/AndroidManifest.xml
index f25b2e5328..6f91fb928c 100644
--- a/osu.Game.Tests.Android/AndroidManifest.xml
+++ b/osu.Game.Tests.Android/AndroidManifest.xml
@@ -1,5 +1,5 @@
-
+
\ No newline at end of file
diff --git a/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
similarity index 60%
rename from osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs
rename to osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
index c876316be4..da46392e4b 100644
--- a/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs
+++ b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
@@ -8,6 +8,9 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Scoring;
+using osu.Game.Scoring.Legacy;
using osu.Game.Screens.Play;
using osu.Game.Tests.Beatmaps.IO;
using osu.Game.Tests.Visual;
@@ -15,7 +18,7 @@ using osu.Game.Tests.Visual;
namespace osu.Game.Tests.Database
{
[HeadlessTest]
- public partial class BackgroundBeatmapProcessorTests : OsuTestScene, ILocalUserPlayInfo
+ public partial class BackgroundDataStoreProcessorTests : OsuTestScene, ILocalUserPlayInfo
{
public IBindable IsPlaying => isPlaying;
@@ -59,7 +62,7 @@ namespace osu.Game.Tests.Database
AddStep("Run background processor", () =>
{
- Add(new TestBackgroundBeatmapProcessor());
+ Add(new TestBackgroundDataStoreProcessor());
});
AddUntilStep("wait for difficulties repopulated", () =>
@@ -98,7 +101,7 @@ namespace osu.Game.Tests.Database
AddStep("Run background processor", () =>
{
- Add(new TestBackgroundBeatmapProcessor());
+ Add(new TestBackgroundDataStoreProcessor());
});
AddWaitStep("wait some", 500);
@@ -124,7 +127,58 @@ namespace osu.Game.Tests.Database
});
}
- public partial class TestBackgroundBeatmapProcessor : BackgroundBeatmapProcessor
+ [Test]
+ public void TestScoreUpgradeSuccess()
+ {
+ ScoreInfo scoreInfo = null!;
+
+ AddStep("Add score which requires upgrade (and has beatmap)", () =>
+ {
+ Realm.Write(r =>
+ {
+ r.Add(scoreInfo = new ScoreInfo(ruleset: r.All().First(), beatmap: r.All().First())
+ {
+ TotalScoreVersion = 30000002,
+ LegacyTotalScore = 123456,
+ IsLegacyScore = true,
+ });
+ });
+ });
+
+ AddStep("Run background processor", () => Add(new TestBackgroundDataStoreProcessor()));
+
+ AddUntilStep("Score version upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(LegacyScoreEncoder.LATEST_VERSION));
+ AddAssert("Score not marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.False);
+ }
+
+ [Test]
+ public void TestScoreUpgradeFailed()
+ {
+ ScoreInfo scoreInfo = null!;
+
+ AddStep("Add score which requires upgrade (but has no beatmap)", () =>
+ {
+ Realm.Write(r =>
+ {
+ r.Add(scoreInfo = new ScoreInfo(ruleset: r.All().First(), beatmap: new BeatmapInfo
+ {
+ BeatmapSet = new BeatmapSetInfo(),
+ Ruleset = r.All().First(),
+ })
+ {
+ TotalScoreVersion = 30000002,
+ IsLegacyScore = true,
+ });
+ });
+ });
+
+ AddStep("Run background processor", () => Add(new TestBackgroundDataStoreProcessor()));
+
+ AddUntilStep("Score marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.True);
+ AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000002));
+ }
+
+ public partial class TestBackgroundDataStoreProcessor : BackgroundDataStoreProcessor
{
protected override int TimeToSleepDuringGameplay => 10;
}
diff --git a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
index b237556d11..0144c0bf97 100644
--- a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
+++ b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs
@@ -1,19 +1,23 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using System.Collections.Generic;
using System.IO;
+using System.IO.Compression;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Platform;
using osu.Framework.Testing;
+using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.IO;
+using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Database
{
[TestFixture]
- public class LegacyBeatmapImporterTest
+ public class LegacyBeatmapImporterTest : RealmTest
{
private readonly TestLegacyBeatmapImporter importer = new TestLegacyBeatmapImporter();
@@ -60,6 +64,33 @@ namespace osu.Game.Tests.Database
}
}
+ [Test]
+ public void TestStableDateAddedApplied()
+ {
+ RunTestWithRealmAsync(async (realm, storage) =>
+ {
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
+ using (var tmpStorage = new TemporaryNativeStorage("stable-songs-folder"))
+ {
+ var stableStorage = new StableStorage(tmpStorage.GetFullPath(""), host);
+ var songsStorage = stableStorage.GetStorageForDirectory(StableStorage.STABLE_DEFAULT_SONGS_PATH);
+
+ ZipFile.ExtractToDirectory(TestResources.GetQuickTestBeatmapForImport(), songsStorage.GetFullPath("renatus"));
+
+ string[] beatmaps = Directory.GetFiles(songsStorage.GetFullPath("renatus"), "*.osu", SearchOption.TopDirectoryOnly);
+
+ File.SetLastWriteTimeUtc(beatmaps[beatmaps.Length / 2], new DateTime(2000, 1, 1, 12, 0, 0));
+
+ await new LegacyBeatmapImporter(new BeatmapImporter(storage, realm)).ImportFromStableAsync(stableStorage);
+
+ var importedSet = realm.Realm.All().Single();
+
+ Assert.NotNull(importedSet);
+ Assert.AreEqual(new DateTimeOffset(new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)), importedSet.DateAdded);
+ }
+ });
+ }
+
private class TestLegacyBeatmapImporter : LegacyBeatmapImporter
{
public TestLegacyBeatmapImporter()
diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
index 6399507aa0..e30caac95e 100644
--- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
+++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
@@ -6,6 +6,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps.ControlPoints;
@@ -29,7 +30,7 @@ namespace osu.Game.Tests.Editing
[Cached(typeof(IBeatSnapProvider))]
private readonly EditorBeatmap editorBeatmap;
- protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
+ protected override Container Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both };
public TestSceneHitObjectComposerDistanceSnapping()
{
diff --git a/osu.Game.Tests/Resources/Shaders/sh_TestVertex.vs b/osu.Game.Tests/Resources/Shaders/sh_TestVertex.vs
index 505554bb33..80ed686ba5 100644
--- a/osu.Game.Tests/Resources/Shaders/sh_TestVertex.vs
+++ b/osu.Game.Tests/Resources/Shaders/sh_TestVertex.vs
@@ -13,7 +13,7 @@ layout(location = 4) out mediump vec2 v_BlendRange;
void main(void)
{
// Transform from screen space to masking space.
- highp vec3 maskingPos = g_ToMaskingSpace * vec3(m_Position, 1.0);
+ highp vec3 maskingPos = g_MaskingInfo.ToMaskingSpace * vec3(m_Position, 1.0);
v_MaskingPosition = maskingPos.xy / maskingPos.z;
v_Colour = m_Colour;
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
index a38c481003..ed3bffe5c2 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
@@ -8,7 +8,7 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
@@ -178,7 +178,7 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("distance spacing increased by 0.5", () => editorBeatmap.BeatmapInfo.DistanceSpacing == originalSpacing + 0.5);
}
- public partial class EditorBeatmapContainer : Container
+ public partial class EditorBeatmapContainer : PopoverContainer
{
private readonly IWorkingBeatmap working;
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs
index dfa9fdf03b..635d9f9604 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs
@@ -185,6 +185,37 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("shorten to -10 length", () => path.ExpectedDistance.Value = -10);
}
+ [Test]
+ public void TestGetSegmentEnds()
+ {
+ var positions = new[]
+ {
+ Vector2.Zero,
+ new Vector2(100, 0),
+ new Vector2(100),
+ new Vector2(200, 100),
+ };
+ double[] distances = { 100d, 200d, 300d };
+
+ AddStep("create path", () => path.ControlPoints.AddRange(positions.Select(p => new PathControlPoint(p, PathType.Linear))));
+ AddAssert("segment ends are correct", () => path.GetSegmentEnds(), () => Is.EqualTo(distances.Select(d => d / 300)));
+ AddAssert("segment end positions recovered", () => path.GetSegmentEnds().Select(p => path.PositionAt(p)), () => Is.EqualTo(positions.Skip(1)));
+
+ AddStep("lengthen last segment", () => path.ExpectedDistance.Value = 400);
+ AddAssert("segment ends are correct", () => path.GetSegmentEnds(), () => Is.EqualTo(distances.Select(d => d / 400)));
+ AddAssert("segment end positions recovered", () => path.GetSegmentEnds().Select(p => path.PositionAt(p)), () => Is.EqualTo(positions.Skip(1)));
+
+ AddStep("shorten last segment", () => path.ExpectedDistance.Value = 150);
+ AddAssert("segment ends are correct", () => path.GetSegmentEnds(), () => Is.EqualTo(distances.Select(d => d / 150)));
+ // see remarks in `GetSegmentEnds()` xmldoc (`SliderPath.PositionAt()` clamps progress to [0,1]).
+ AddAssert("segment end positions not recovered", () => path.GetSegmentEnds().Select(p => path.PositionAt(p)), () => Is.EqualTo(new[]
+ {
+ positions[1],
+ new Vector2(100, 50),
+ new Vector2(100, 50),
+ }));
+ }
+
private List createSegment(PathType type, params Vector2[] controlPoints)
{
var points = controlPoints.Select(p => new PathControlPoint { Position = p }).ToList();
diff --git a/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs b/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs
index b12f3e7946..bb327e5962 100644
--- a/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs
+++ b/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs
@@ -44,7 +44,7 @@ namespace osu.Game.Tests.Visual.Menus
foreach (var fountain in Children.OfType())
{
if (RNG.NextSingle() > 0.8f)
- fountain.Shoot();
+ fountain.Shoot(RNG.Next(-1, 2));
}
}, 150);
}
diff --git a/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs b/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs
index 0f31192a9c..e0444b6126 100644
--- a/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs
+++ b/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs
@@ -2,27 +2,36 @@
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
-using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Beatmaps.Legacy;
-using osu.Game.Tests.Visual;
using osu.Game.Tournament.Components;
using osu.Game.Tournament.Models;
namespace osu.Game.Tournament.Tests.Components
{
[TestFixture]
- public partial class TestSceneSongBar : OsuTestScene
+ public partial class TestSceneSongBar : TournamentTestScene
{
- [Cached]
- private readonly LadderInfo ladder = new LadderInfo();
-
private SongBar songBar = null!;
+ private TournamentBeatmap ladderBeatmap = null!;
[SetUpSteps]
- public void SetUpSteps()
+ public override void SetUpSteps()
{
+ base.SetUpSteps();
+
+ AddStep("setup picks bans", () =>
+ {
+ ladderBeatmap = CreateSampleBeatmap();
+ Ladder.CurrentMatch.Value!.PicksBans.Add(new BeatmapChoice
+ {
+ BeatmapID = ladderBeatmap.OnlineID,
+ Team = TeamColour.Red,
+ Type = ChoiceType.Pick,
+ });
+ });
+
AddStep("create bar", () => Child = songBar = new SongBar
{
RelativeSizeAxes = Axes.X,
@@ -38,12 +47,14 @@ namespace osu.Game.Tournament.Tests.Components
AddStep("set beatmap", () =>
{
var beatmap = CreateAPIBeatmap(Ruleset.Value);
+
beatmap.CircleSize = 3.4f;
beatmap.ApproachRate = 6.8f;
beatmap.OverallDifficulty = 5.5f;
beatmap.StarRating = 4.56f;
beatmap.Length = 123456;
beatmap.BPM = 133;
+ beatmap.OnlineID = ladderBeatmap.OnlineID;
songBar.Beatmap = new TournamentBeatmap(beatmap);
});
diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs
index 3d060600f7..cde826628e 100644
--- a/osu.Game.Tournament/Components/SongBar.cs
+++ b/osu.Game.Tournament/Components/SongBar.cs
@@ -234,7 +234,7 @@ namespace osu.Game.Tournament.Components
}
}
},
- new UnmaskedTournamentBeatmapPanel(beatmap)
+ new TournamentBeatmapPanel(beatmap)
{
RelativeSizeAxes = Axes.X,
Width = 0.5f,
@@ -277,18 +277,4 @@ namespace osu.Game.Tournament.Components
}
}
}
-
- internal partial class UnmaskedTournamentBeatmapPanel : TournamentBeatmapPanel
- {
- public UnmaskedTournamentBeatmapPanel(IBeatmapInfo? beatmap, string mod = "")
- : base(beatmap, mod)
- {
- }
-
- [BackgroundDependencyLoader]
- private void load()
- {
- Masking = false;
- }
- }
}
diff --git a/osu.Game.Tournament/Models/TournamentTeam.cs b/osu.Game.Tournament/Models/TournamentTeam.cs
index 5df8c2a620..ae0ac77936 100644
--- a/osu.Game.Tournament/Models/TournamentTeam.cs
+++ b/osu.Game.Tournament/Models/TournamentTeam.cs
@@ -51,7 +51,7 @@ namespace osu.Game.Tournament.Models
public Bindable LastYearPlacing = new BindableInt
{
- MinValue = 1,
+ MinValue = 0,
MaxValue = 256
};
diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs
index 241692d515..250d5acaae 100644
--- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs
+++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs
@@ -10,7 +10,9 @@ using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
+using osu.Framework.Localisation;
using osu.Game.Graphics;
+using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osu.Game.Overlays.Settings;
using osu.Game.Tournament.Models;
@@ -128,7 +130,7 @@ namespace osu.Game.Tournament.Screens.Editors
Width = 0.2f,
Current = Model.Seed
},
- new SettingsSlider
+ new SettingsSlider
{
LabelText = "Last Year Placement",
Width = 0.33f,
@@ -175,6 +177,11 @@ namespace osu.Game.Tournament.Screens.Editors
};
}
+ private partial class LastYearPlacementSlider : RoundedSliderBar
+ {
+ public override LocalisableString TooltipText => Current.Value == 0 ? "N/A" : base.TooltipText;
+ }
+
public partial class PlayerEditor : CompositeDrawable
{
private readonly TournamentTeam team;
diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs
index 120a76c127..899d462e4e 100644
--- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs
+++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs
@@ -274,7 +274,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro
new TeamDisplay(team) { Margin = new MarginPadding { Bottom = 30 } },
new RowDisplay("Average Rank:", $"#{team.AverageRank:#,0}"),
new RowDisplay("Seed:", team.Seed.Value),
- new RowDisplay("Last year's placing:", team.LastYearPlacing.Value > 0 ? $"#{team.LastYearPlacing:#,0}" : "0"),
+ new RowDisplay("Last year's placing:", team.LastYearPlacing.Value > 0 ? $"#{team.LastYearPlacing:#,0}" : "N/A"),
new Container { Margin = new MarginPadding { Bottom = 30 } },
}
},
diff --git a/osu.Game/BackgroundBeatmapProcessor.cs b/osu.Game/BackgroundDataStoreProcessor.cs
similarity index 93%
rename from osu.Game/BackgroundBeatmapProcessor.cs
rename to osu.Game/BackgroundDataStoreProcessor.cs
index b553fee503..f29b100ee8 100644
--- a/osu.Game/BackgroundBeatmapProcessor.cs
+++ b/osu.Game/BackgroundDataStoreProcessor.cs
@@ -24,7 +24,10 @@ using osu.Game.Screens.Play;
namespace osu.Game
{
- public partial class BackgroundBeatmapProcessor : Component
+ ///
+ /// Performs background updating of data stores at startup.
+ ///
+ public partial class BackgroundDataStoreProcessor : Component
{
[Resolved]
private RulesetStore rulesetStore { get; set; } = null!;
@@ -61,7 +64,8 @@ namespace osu.Game
Task.Factory.StartNew(() =>
{
- Logger.Log("Beginning background beatmap processing..");
+ Logger.Log("Beginning background data store processing..");
+
checkForOutdatedStarRatings();
processBeatmapSetsWithMissingMetrics();
processScoresWithMissingStatistics();
@@ -74,7 +78,7 @@ namespace osu.Game
return;
}
- Logger.Log("Finished background beatmap processing!");
+ Logger.Log("Finished background data store processing!");
});
}
@@ -182,7 +186,7 @@ namespace osu.Game
realmAccess.Run(r =>
{
- foreach (var score in r.All())
+ foreach (var score in r.All().Where(s => !s.BackgroundReprocessingFailed))
{
if (score.BeatmapInfo != null
&& score.Statistics.Sum(kvp => kvp.Value) > 0
@@ -221,6 +225,7 @@ namespace osu.Game
catch (Exception e)
{
Logger.Log(@$"Failed to populate maximum statistics for {id}: {e}");
+ realmAccess.Write(r => r.Find(id)!.BackgroundReprocessingFailed = true);
}
}
}
@@ -230,7 +235,7 @@ namespace osu.Game
Logger.Log("Querying for scores that need total score conversion...");
HashSet scoreIds = realmAccess.Run(r => new HashSet(r.All()
- .Where(s => s.BeatmapInfo != null && s.TotalScoreVersion == 30000002)
+ .Where(s => !s.BackgroundReprocessingFailed && s.BeatmapInfo != null && s.TotalScoreVersion == 30000002)
.AsEnumerable().Select(s => s.ID)));
Logger.Log($"Found {scoreIds.Count} scores which require total score conversion.");
@@ -279,6 +284,7 @@ namespace osu.Game
catch (Exception e)
{
Logger.Log($"Failed to convert total score for {id}: {e}");
+ realmAccess.Write(r => r.Find(id)!.BackgroundReprocessingFailed = true);
++failedCount;
}
}
diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs
index c840b4fa94..14719da1bc 100644
--- a/osu.Game/Beatmaps/BeatmapImporter.cs
+++ b/osu.Game/Beatmaps/BeatmapImporter.cs
@@ -152,6 +152,8 @@ namespace osu.Game.Beatmaps
if (archive != null)
beatmapSet.Beatmaps.AddRange(createBeatmapDifficulties(beatmapSet, realm));
+ beatmapSet.DateAdded = getDateAdded(archive);
+
foreach (BeatmapInfo b in beatmapSet.Beatmaps)
{
b.BeatmapSet = beatmapSet;
@@ -305,11 +307,36 @@ namespace osu.Game.Beatmaps
return new BeatmapSetInfo
{
OnlineID = beatmap.BeatmapInfo.BeatmapSet?.OnlineID ?? -1,
- // Metadata = beatmap.Metadata,
- DateAdded = DateTimeOffset.UtcNow
};
}
+ ///
+ /// Determine the date a given beatmapset has been added to the game.
+ /// For legacy imports, we can use the oldest file write time for any `.osu` file in the directory.
+ /// For any other import types, use "now".
+ ///
+ private DateTimeOffset getDateAdded(ArchiveReader? reader)
+ {
+ DateTimeOffset dateAdded = DateTimeOffset.UtcNow;
+
+ if (reader is LegacyDirectoryArchiveReader legacyReader)
+ {
+ var beatmaps = reader.Filenames.Where(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
+
+ dateAdded = File.GetLastWriteTimeUtc(legacyReader.GetFullPath(beatmaps.First()));
+
+ foreach (string beatmapName in beatmaps)
+ {
+ var currentDateAdded = File.GetLastWriteTimeUtc(legacyReader.GetFullPath(beatmapName));
+
+ if (currentDateAdded < dateAdded)
+ dateAdded = currentDateAdded;
+ }
+ }
+
+ return dateAdded;
+ }
+
///
/// Create all required s for the provided archive.
///
diff --git a/osu.Game/Beatmaps/Formats/Decoder.cs b/osu.Game/Beatmaps/Formats/Decoder.cs
index 4f0f11d053..c007f5dcdc 100644
--- a/osu.Game/Beatmaps/Formats/Decoder.cs
+++ b/osu.Game/Beatmaps/Formats/Decoder.cs
@@ -1,13 +1,10 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
-using JetBrains.Annotations;
using osu.Game.IO;
using osu.Game.Rulesets;
@@ -45,7 +42,7 @@ namespace osu.Game.Beatmaps.Formats
/// Register dependencies for use with static decoder classes.
///
/// A store containing all available rulesets (used by ).
- public static void RegisterDependencies([NotNull] RulesetStore rulesets)
+ public static void RegisterDependencies(RulesetStore rulesets)
{
LegacyBeatmapDecoder.RulesetStore = rulesets ?? throw new ArgumentNullException(nameof(rulesets));
}
@@ -63,7 +60,7 @@ namespace osu.Game.Beatmaps.Formats
throw new IOException(@"Unknown decoder type");
// start off with the first line of the file
- string line = stream.PeekLine()?.Trim();
+ string? line = stream.PeekLine()?.Trim();
while (line != null && line.Length == 0)
{
diff --git a/osu.Game/Beatmaps/Formats/IHasComboColours.cs b/osu.Game/Beatmaps/Formats/IHasComboColours.cs
index 1d9cc0be65..1608adee7d 100644
--- a/osu.Game/Beatmaps/Formats/IHasComboColours.cs
+++ b/osu.Game/Beatmaps/Formats/IHasComboColours.cs
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System.Collections.Generic;
using osuTK.Graphics;
@@ -13,7 +11,7 @@ namespace osu.Game.Beatmaps.Formats
///
/// Retrieves the list of combo colours for presentation only.
///
- IReadOnlyList ComboColours { get; }
+ IReadOnlyList? ComboColours { get; }
///
/// The list of custom combo colours.
diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs
index 65a01befb4..5da51f6c8e 100644
--- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
#pragma warning disable 618
using System;
@@ -36,11 +34,11 @@ namespace osu.Game.Beatmaps.Formats
///
private const double control_point_leniency = 1;
- internal static RulesetStore RulesetStore;
+ internal static RulesetStore? RulesetStore;
- private Beatmap beatmap;
+ private Beatmap beatmap = null!;
- private ConvertHitObjectParser parser;
+ private ConvertHitObjectParser? parser;
private LegacySampleBank defaultSampleBank;
private int defaultSampleVolume = 100;
@@ -222,7 +220,7 @@ namespace osu.Game.Beatmaps.Formats
case @"Mode":
int rulesetID = Parsing.ParseInt(pair.Value);
- beatmap.BeatmapInfo.Ruleset = RulesetStore.GetRuleset(rulesetID) ?? throw new ArgumentException("Ruleset is not available locally.");
+ beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(rulesetID) ?? throw new ArgumentException("Ruleset is not available locally.");
switch (rulesetID)
{
diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
index 041b00c7e1..fc9de13c89 100644
--- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
@@ -1,15 +1,12 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
-using JetBrains.Annotations;
using osu.Game.Audio;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Legacy;
@@ -34,8 +31,7 @@ namespace osu.Game.Beatmaps.Formats
private readonly IBeatmap beatmap;
- [CanBeNull]
- private readonly ISkin skin;
+ private readonly ISkin? skin;
private readonly int onlineRulesetID;
@@ -44,7 +40,7 @@ namespace osu.Game.Beatmaps.Formats
///
/// The beatmap to encode.
/// The beatmap's skin, used for encoding combo colours.
- public LegacyBeatmapEncoder(IBeatmap beatmap, [CanBeNull] ISkin skin)
+ public LegacyBeatmapEncoder(IBeatmap beatmap, ISkin? skin)
{
this.beatmap = beatmap;
this.skin = skin;
@@ -180,8 +176,8 @@ namespace osu.Game.Beatmaps.Formats
writer.WriteLine("[TimingPoints]");
- SampleControlPoint lastRelevantSamplePoint = null;
- DifficultyControlPoint lastRelevantDifficultyPoint = null;
+ SampleControlPoint? lastRelevantSamplePoint = null;
+ DifficultyControlPoint? lastRelevantDifficultyPoint = null;
// In osu!taiko and osu!mania, a scroll speed is stored as "slider velocity" in legacy formats.
// In that case, a scrolling speed change is a global effect and per-hit object difficulty control points are ignored.
@@ -585,7 +581,7 @@ namespace osu.Game.Beatmaps.Formats
return type;
}
- private LegacySampleBank toLegacySampleBank(string sampleBank)
+ private LegacySampleBank toLegacySampleBank(string? sampleBank)
{
switch (sampleBank?.ToLowerInvariant())
{
@@ -603,7 +599,7 @@ namespace osu.Game.Beatmaps.Formats
}
}
- private int toLegacyCustomSampleBank(HitSampleInfo hitSampleInfo)
+ private int toLegacyCustomSampleBank(HitSampleInfo? hitSampleInfo)
{
if (hitSampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy)
return legacy.CustomSampleBank;
diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs
index df5d3edb55..cf4700bf85 100644
--- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Collections.Generic;
using System.IO;
@@ -19,10 +17,10 @@ namespace osu.Game.Beatmaps.Formats
{
public class LegacyStoryboardDecoder : LegacyDecoder
{
- private StoryboardSprite storyboardSprite;
- private CommandTimelineGroup timelineGroup;
+ private StoryboardSprite? storyboardSprite;
+ private CommandTimelineGroup? timelineGroup;
- private Storyboard storyboard;
+ private Storyboard storyboard = null!;
private readonly Dictionary variables = new Dictionary();
diff --git a/osu.Game/Database/LegacyBeatmapExporter.cs b/osu.Game/Database/LegacyBeatmapExporter.cs
index a874353f73..ece705f685 100644
--- a/osu.Game/Database/LegacyBeatmapExporter.cs
+++ b/osu.Game/Database/LegacyBeatmapExporter.cs
@@ -70,7 +70,22 @@ namespace osu.Game.Database
hitObject.StartTime = Math.Floor(hitObject.StartTime);
- if (hitObject is not IHasPath hasPath || BezierConverter.CountSegments(hasPath.Path.ControlPoints) <= 1) continue;
+ if (hitObject is not IHasPath hasPath) continue;
+
+ // stable's hit object parsing expects the entire slider to use only one type of curve,
+ // and happens to use the last non-empty curve type read for the entire slider.
+ // this clear of the last control point type handles an edge case
+ // wherein the last control point of an otherwise-single-segment slider path has a different type than previous,
+ // which would lead to sliders being mangled when exported back to stable.
+ // normally, that would be handled by the `BezierConverter.ConvertToModernBezier()` call below,
+ // which outputs a slider path containing only Bezier control points,
+ // but a non-inherited last control point is (rightly) not considered to be starting a new segment,
+ // therefore it would fail to clear the `CountSegments() <= 1` check.
+ // by clearing explicitly we both fix the issue and avoid unnecessary conversions to Bezier.
+ if (hasPath.Path.ControlPoints.Count > 1)
+ hasPath.Path.ControlPoints[^1].Type = null;
+
+ if (BezierConverter.CountSegments(hasPath.Path.ControlPoints) <= 1) continue;
var newControlPoints = BezierConverter.ConvertToModernBezier(hasPath.Path.ControlPoints);
diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index 917d662255..cd97bb6430 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -83,8 +83,9 @@ namespace osu.Game.Database
/// 31 2023-06-26 Add Version and LegacyTotalScore to ScoreInfo, set Version to 30000002 and copy TotalScore into LegacyTotalScore for legacy scores.
/// 32 2023-07-09 Populate legacy scores with the ScoreV2 mod (and restore TotalScore to the legacy total for such scores) using replay files.
/// 33 2023-08-16 Reset default chat toggle key binding to avoid conflict with newly added leaderboard toggle key binding.
+ /// 34 2023-08-21 Add BackgroundReprocessingFailed flag to ScoreInfo to track upgrade failures.
///
- private const int schema_version = 33;
+ private const int schema_version = 34;
///
/// Lock object which is held during sections, blocking realm retrieval during blocking periods.
diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs
index eb046932e6..2f2cb7e5f8 100644
--- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs
+++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs
@@ -40,8 +40,14 @@ namespace osu.Game.Graphics.UserInterface
AddInternal(hoverClickSounds = new HoverClickSounds());
updateTextColour();
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
Item.Action.BindDisabledChanged(_ => updateState(), true);
+ FinishTransforms();
}
private void updateTextColour()
diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
index 454be02d0b..8b9d35e343 100644
--- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
@@ -35,6 +35,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
public string Text
{
+ get => Component.Text;
set => Component.Text = value;
}
diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
index fc0e4d2083..37ea2a3f96 100644
--- a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
@@ -85,6 +85,8 @@ namespace osu.Game.Graphics.UserInterfaceV2
Current.BindValueChanged(updateTextBoxFromSlider, true);
}
+ public bool TakeFocus() => GetContainingInputManager().ChangeFocus(textBox);
+
private bool updatingFromTextBox;
private void textChanged(ValueChangedEvent change)
diff --git a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs
index dfae58aed7..1503705022 100644
--- a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs
+++ b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs
@@ -21,7 +21,9 @@ namespace osu.Game.IO.Archives
this.path = Path.GetFullPath(path);
}
- public override Stream GetStream(string name) => File.OpenRead(Path.Combine(path, name));
+ public override Stream GetStream(string name) => File.OpenRead(GetFullPath(name));
+
+ public string GetFullPath(string filename) => Path.Combine(path, filename);
public override void Dispose()
{
diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs
index 1090eeb462..296232d9ea 100644
--- a/osu.Game/Input/Bindings/GlobalActionContainer.cs
+++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs
@@ -3,33 +3,26 @@
using System.Collections.Generic;
using System.Linq;
-using osu.Framework.Graphics;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
+using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Localisation;
namespace osu.Game.Input.Bindings
{
- public partial class GlobalActionContainer : DatabasedKeyBindingContainer, IHandleGlobalKeyboardInput
+ public partial class GlobalActionContainer : DatabasedKeyBindingContainer, IHandleGlobalKeyboardInput, IKeyBindingHandler
{
- private readonly Drawable? handler;
-
- private InputManager? parentInputManager;
+ private readonly IKeyBindingHandler? handler;
public GlobalActionContainer(OsuGameBase? game)
: base(matchingMode: KeyCombinationMatchingMode.Modifiers)
{
- if (game is IKeyBindingHandler)
- handler = game;
+ if (game is IKeyBindingHandler h)
+ handler = h;
}
- protected override void LoadComplete()
- {
- base.LoadComplete();
-
- parentInputManager = GetContainingInputManager();
- }
+ protected override bool Prioritised => true;
// IMPORTANT: Take care when changing order of the items in the enumerable.
// It is used to decide the order of precedence, with the earlier items having higher precedence.
@@ -105,6 +98,7 @@ namespace osu.Game.Input.Bindings
// See https://github.com/ppy/osu-framework/blob/master/osu.Framework/Input/StateChanges/MouseScrollRelativeInput.cs#L37-L38.
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelRight }, GlobalAction.EditorCyclePreviousBeatSnapDivisor),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelLeft }, GlobalAction.EditorCycleNextBeatSnapDivisor),
+ new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.EditorToggleRotateControl),
};
public IEnumerable InGameKeyBindings => new[]
@@ -160,20 +154,9 @@ namespace osu.Game.Input.Bindings
new KeyBinding(InputKey.F3, GlobalAction.MusicPlay)
};
- protected override IEnumerable KeyBindingInputQueue
- {
- get
- {
- // To ensure the global actions are handled with priority, this GlobalActionContainer is actually placed after game content.
- // It does not contain children as expected, so we need to forward the NonPositionalInputQueue from the parent input manager to correctly
- // allow the whole game to handle these actions.
+ public bool OnPressed(KeyBindingPressEvent e) => handler?.OnPressed(e) == true;
- // An eventual solution to this hack is to create localised action containers for individual components like SongSelect, but this will take some rearranging.
- var inputQueue = parentInputManager?.NonPositionalInputQueue ?? base.KeyBindingInputQueue;
-
- return handler != null ? inputQueue.Prepend(handler) : inputQueue;
- }
- }
+ public void OnReleased(KeyBindingReleaseEvent e) => handler?.OnReleased(e);
}
public enum GlobalAction
@@ -378,5 +361,8 @@ namespace osu.Game.Input.Bindings
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleInGameLeaderboard))]
ToggleInGameLeaderboard,
+
+ [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleRotateControl))]
+ EditorToggleRotateControl,
}
}
diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs
index ceefc27968..8356c480dd 100644
--- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs
+++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs
@@ -344,6 +344,11 @@ namespace osu.Game.Localisation
///
public static LocalisableString ExportReplay => new TranslatableString(getKey(@"export_replay"), @"Export replay");
+ ///
+ /// "Toggle rotate control"
+ ///
+ public static LocalisableString EditorToggleRotateControl => new TranslatableString(getKey(@"editor_toggle_rotate_control"), @"Toggle rotate control");
+
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
diff --git a/osu.Game/Localisation/OnlinePlayStrings.cs b/osu.Game/Localisation/OnlinePlayStrings.cs
new file mode 100644
index 0000000000..1853cb753a
--- /dev/null
+++ b/osu.Game/Localisation/OnlinePlayStrings.cs
@@ -0,0 +1,19 @@
+// 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 OnlinePlayStrings
+ {
+ private const string prefix = @"osu.Game.Resources.Localisation.OnlinePlay";
+
+ ///
+ /// "Playlist durations longer than 2 weeks require an active osu!supporter tag."
+ ///
+ public static LocalisableString SupporterOnlyDurationNotice => new TranslatableString(getKey(@"supporter_only_duration_notice"), @"Playlist durations longer than 2 weeks require an active osu!supporter tag.");
+
+ private static string getKey(string key) => $@"{prefix}:{key}";
+ }
+}
diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 1a40bb8e3d..c60bff9e4c 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -1025,7 +1025,7 @@ namespace osu.Game
loadComponentSingleFile(CreateHighPerformanceSession(), Add);
- loadComponentSingleFile(new BackgroundBeatmapProcessor(), Add);
+ loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add);
Add(difficultyRecommender);
Add(externalLinkOpener = new ExternalLinkOpener());
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index 6737caa5f9..75b46a0a4d 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -392,17 +392,18 @@ namespace osu.Game
{
SafeAreaOverrideEdges = SafeAreaOverrideEdges,
RelativeSizeAxes = Axes.Both,
- Child = CreateScalingContainer().WithChildren(new Drawable[]
+ Child = CreateScalingContainer().WithChild(globalBindings = new GlobalActionContainer(this)
{
- (GlobalCursorDisplay = new GlobalCursorDisplay
+ Children = new Drawable[]
{
- RelativeSizeAxes = Axes.Both
- }).WithChild(content = new OsuTooltipContainer(GlobalCursorDisplay.MenuCursor)
- {
- RelativeSizeAxes = Axes.Both
- }),
- // to avoid positional input being blocked by children, ensure the GlobalActionContainer is above everything.
- globalBindings = new GlobalActionContainer(this)
+ (GlobalCursorDisplay = new GlobalCursorDisplay
+ {
+ RelativeSizeAxes = Axes.Both
+ }).WithChild(content = new OsuTooltipContainer(GlobalCursorDisplay.MenuCursor)
+ {
+ RelativeSizeAxes = Axes.Both
+ }),
+ }
})
});
diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs
index 028f8b6839..34113285a4 100644
--- a/osu.Game/Rulesets/Objects/SliderPath.cs
+++ b/osu.Game/Rulesets/Objects/SliderPath.cs
@@ -40,11 +40,13 @@ namespace osu.Game.Rulesets.Objects
private readonly List calculatedPath = new List();
private readonly List cumulativeLength = new List();
- private readonly List segmentEnds = new List();
private readonly Cached pathCache = new Cached();
private double calculatedLength;
+ private readonly List segmentEnds = new List();
+ private double[] segmentEndDistances = Array.Empty();
+
///
/// Creates a new .
///
@@ -196,13 +198,28 @@ namespace osu.Game.Rulesets.Objects
}
///
- /// Returns the progress values at which segments of the path end.
+ /// Returns the progress values at which (control point) segments of the path end.
+ /// Ranges from 0 (beginning of the path) to 1 (end of the path) to infinity (beyond the end of the path).
///
+ ///
+ /// truncates the progression values to [0,1],
+ /// so you can't use this method in conjunction with that one to retrieve the positions of segment ends beyond the end of the path.
+ ///
+ ///
+ ///
+ /// In case is less than ,
+ /// the last segment ends after the end of the path, hence it returns a value greater than 1.
+ ///
+ ///
+ /// In case is greater than ,
+ /// the last segment ends before the end of the path, hence it returns a value less than 1.
+ ///
+ ///
public IEnumerable GetSegmentEnds()
{
ensureValid();
- return segmentEnds.Select(i => cumulativeLength[i] / calculatedLength);
+ return segmentEndDistances.Select(d => d / Distance);
}
private void invalidate()
@@ -251,8 +268,11 @@ namespace osu.Game.Rulesets.Objects
calculatedPath.Add(t);
}
- // Remember the index of the segment end
- segmentEnds.Add(calculatedPath.Count - 1);
+ if (i > 0)
+ {
+ // Remember the index of the segment end
+ segmentEnds.Add(calculatedPath.Count - 1);
+ }
// Start the new segment at the current vertex
start = i;
@@ -298,6 +318,14 @@ namespace osu.Game.Rulesets.Objects
cumulativeLength.Add(calculatedLength);
}
+ // Store the distances of the segment ends now, because after shortening the indices may be out of range
+ segmentEndDistances = new double[segmentEnds.Count];
+
+ for (int i = 0; i < segmentEnds.Count; i++)
+ {
+ segmentEndDistances[i] = cumulativeLength[segmentEnds[i]];
+ }
+
if (ExpectedDistance.Value is double expectedDistance && calculatedLength != expectedDistance)
{
// In osu-stable, if the last two control points of a slider are equal, extension is not performed.
@@ -319,10 +347,6 @@ namespace osu.Game.Rulesets.Objects
{
cumulativeLength.RemoveAt(cumulativeLength.Count - 1);
calculatedPath.RemoveAt(pathEndIndex--);
-
- // Shorten the last segment to the expected distance
- if (segmentEnds.Count > 0)
- segmentEnds[^1]--;
}
}
diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs
index 92a3b570fb..6c88f01249 100644
--- a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs
+++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects.Types;
@@ -25,6 +26,53 @@ namespace osu.Game.Rulesets.Objects
/// The .
/// The positional offset of the resulting path. It should be added to the start position of this path.
public static void Reverse(this SliderPath sliderPath, out Vector2 positionalOffset)
+ {
+ var controlPoints = sliderPath.ControlPoints;
+
+ var inheritedLinearPoints = controlPoints.Where(p => sliderPath.PointsInSegment(p)[0].Type == PathType.Linear && p.Type is null).ToList();
+
+ // Inherited points after a linear point, as well as the first control point if it inherited,
+ // should be treated as linear points, so their types are temporarily changed to linear.
+ inheritedLinearPoints.ForEach(p => p.Type = PathType.Linear);
+
+ double[] segmentEnds = sliderPath.GetSegmentEnds().ToArray();
+
+ // Remove segments after the end of the slider.
+ for (int numSegmentsToRemove = segmentEnds.Count(se => se >= 1) - 1; numSegmentsToRemove > 0 && controlPoints.Count > 0;)
+ {
+ if (controlPoints.Last().Type is not null)
+ {
+ numSegmentsToRemove--;
+ segmentEnds = segmentEnds[..^1];
+ }
+
+ controlPoints.RemoveAt(controlPoints.Count - 1);
+ }
+
+ // Restore original control point types.
+ inheritedLinearPoints.ForEach(p => p.Type = null);
+
+ // Recalculate middle perfect curve control points at the end of the slider path.
+ if (controlPoints.Count >= 3 && controlPoints[^3].Type == PathType.PerfectCurve && controlPoints[^2].Type is null && segmentEnds.Any())
+ {
+ double lastSegmentStart = segmentEnds.Length > 1 ? segmentEnds[^2] : 0;
+ double lastSegmentEnd = segmentEnds[^1];
+
+ var circleArcPath = new List();
+ sliderPath.GetPathToProgress(circleArcPath, lastSegmentStart / lastSegmentEnd, 1);
+
+ controlPoints[^2].Position = circleArcPath[circleArcPath.Count / 2];
+ }
+
+ sliderPath.reverseControlPoints(out positionalOffset);
+ }
+
+ ///
+ /// Reverses the order of the provided 's s.
+ ///
+ /// The .
+ /// The positional offset of the resulting path. It should be added to the start position of this path.
+ private static void reverseControlPoints(this SliderPath sliderPath, out Vector2 positionalOffset)
{
var points = sliderPath.ControlPoints.ToArray();
positionalOffset = sliderPath.PositionAt(1);
diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs
index c6f4433824..2efea2105c 100644
--- a/osu.Game/Scoring/ScoreInfo.cs
+++ b/osu.Game/Scoring/ScoreInfo.cs
@@ -66,7 +66,7 @@ namespace osu.Game.Scoring
/// If this does not match ,
/// the total score has not yet been updated to reflect the current scoring values.
///
- /// See 's conversion logic.
+ /// See 's conversion logic.
///
///
/// This may not match the version stored in the replay files.
@@ -81,6 +81,15 @@ namespace osu.Game.Scoring
///
public long? LegacyTotalScore { get; set; }
+ ///
+ /// If background processing of this beatmap failed in some way, this flag will become true.
+ /// Should be used to ensure we don't repeatedly attempt to reprocess the same scores each startup even though we already know they will fail.
+ ///
+ ///
+ /// See https://github.com/ppy/osu/issues/24301 for one example of how this can occur (missing beatmap file on disk).
+ ///
+ public bool BackgroundReprocessingFailed { get; set; }
+
public int MaxCombo { get; set; }
public double Accuracy { get; set; }
diff --git a/osu.Game/Screens/Edit/Components/EditorToolButton.cs b/osu.Game/Screens/Edit/Components/EditorToolButton.cs
new file mode 100644
index 0000000000..6550362687
--- /dev/null
+++ b/osu.Game/Screens/Edit/Components/EditorToolButton.cs
@@ -0,0 +1,107 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Localisation;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Overlays;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Screens.Edit.Components
+{
+ public partial class EditorToolButton : OsuButton, IHasPopover
+ {
+ public BindableBool Selected { get; } = new BindableBool();
+
+ private readonly Func createIcon;
+ private readonly Func createPopover;
+
+ private Color4 defaultBackgroundColour;
+ private Color4 defaultIconColour;
+ private Color4 selectedBackgroundColour;
+ private Color4 selectedIconColour;
+
+ private Drawable icon = null!;
+
+ public EditorToolButton(LocalisableString text, Func createIcon, Func createPopover)
+ {
+ Text = text;
+ this.createIcon = createIcon;
+ this.createPopover = createPopover;
+
+ RelativeSizeAxes = Axes.X;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OverlayColourProvider colourProvider)
+ {
+ defaultBackgroundColour = colourProvider.Background3;
+ selectedBackgroundColour = colourProvider.Background1;
+
+ defaultIconColour = defaultBackgroundColour.Darken(0.5f);
+ selectedIconColour = selectedBackgroundColour.Lighten(0.5f);
+
+ Add(icon = createIcon().With(b =>
+ {
+ b.Blending = BlendingParameters.Additive;
+ b.Anchor = Anchor.CentreLeft;
+ b.Origin = Anchor.CentreLeft;
+ b.Size = new Vector2(20);
+ b.X = 10;
+ }));
+
+ Action = Selected.Toggle;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ Selected.BindValueChanged(_ => updateSelectionState(), true);
+ }
+
+ private void updateSelectionState()
+ {
+ if (!IsLoaded)
+ return;
+
+ BackgroundColour = Selected.Value ? selectedBackgroundColour : defaultBackgroundColour;
+ icon.Colour = Selected.Value ? selectedIconColour : defaultIconColour;
+
+ if (Selected.Value)
+ this.ShowPopover();
+ else
+ this.HidePopover();
+ }
+
+ protected override SpriteText CreateText() => new OsuSpriteText
+ {
+ Depth = -1,
+ Origin = Anchor.CentreLeft,
+ Anchor = Anchor.CentreLeft,
+ X = 40f
+ };
+
+ public Popover? GetPopover() => Enabled.Value
+ ? createPopover()?.With(p =>
+ {
+ p.State.BindValueChanged(state =>
+ {
+ if (state.NewValue == Visibility.Hidden)
+ Selected.Value = false;
+ });
+ })
+ : null;
+ }
+}
diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
index 1de6c8364c..110beb0fa6 100644
--- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
@@ -34,7 +34,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
public Container> SelectionBlueprints { get; private set; }
- protected SelectionHandler SelectionHandler { get; private set; }
+ public SelectionHandler SelectionHandler { get; private set; }
private readonly Dictionary> blueprintMap = new Dictionary>();
diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
index 158b4066bc..3c859c65ff 100644
--- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
@@ -55,7 +55,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
[Resolved(CanBeNull = true)]
protected IEditorChangeHandler ChangeHandler { get; private set; }
- protected SelectionRotationHandler RotationHandler { get; private set; }
+ public SelectionRotationHandler RotationHandler { get; private set; }
protected SelectionHandler()
{
diff --git a/osu.Game/Screens/Loader.cs b/osu.Game/Screens/Loader.cs
index 372cfe748e..962c7d9d14 100644
--- a/osu.Game/Screens/Loader.cs
+++ b/osu.Game/Screens/Loader.cs
@@ -126,12 +126,9 @@ namespace osu.Game.Screens
private void load(ShaderManager manager)
{
loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE));
- loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.BLUR));
-
+ loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2_NO_MASKING, FragmentShaderDescriptor.BLUR));
loadTargets.Add(manager.Load(@"CursorTrail", FragmentShaderDescriptor.TEXTURE));
-
loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, "TriangleBorder"));
-
loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_3, FragmentShaderDescriptor.TEXTURE));
}
diff --git a/osu.Game/Screens/Menu/KiaiMenuFountains.cs b/osu.Game/Screens/Menu/KiaiMenuFountains.cs
index a4d58e398a..07c06dcdb9 100644
--- a/osu.Game/Screens/Menu/KiaiMenuFountains.cs
+++ b/osu.Game/Screens/Menu/KiaiMenuFountains.cs
@@ -2,10 +2,10 @@
// See the LICENCE file in the repository root for full licence text.
using System;
-using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Graphics;
+using osu.Framework.Utils;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics.Containers;
@@ -13,6 +13,9 @@ namespace osu.Game.Screens.Menu
{
public partial class KiaiMenuFountains : BeatSyncedContainer
{
+ private StarFountain leftFountain = null!;
+ private StarFountain rightFountain = null!;
+
[BackgroundDependencyLoader]
private void load()
{
@@ -20,13 +23,13 @@ namespace osu.Game.Screens.Menu
Children = new[]
{
- new StarFountain
+ leftFountain = new StarFountain
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
X = 250,
},
- new StarFountain
+ rightFountain = new StarFountain
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
@@ -58,8 +61,25 @@ namespace osu.Game.Screens.Menu
if (lastTrigger != null && Clock.CurrentTime - lastTrigger < 500)
return;
- foreach (var fountain in Children.OfType())
- fountain.Shoot();
+ int direction = RNG.Next(-1, 2);
+
+ switch (direction)
+ {
+ case -1:
+ leftFountain.Shoot(1);
+ rightFountain.Shoot(-1);
+ break;
+
+ case 0:
+ leftFountain.Shoot(0);
+ rightFountain.Shoot(0);
+ break;
+
+ case 1:
+ leftFountain.Shoot(-1);
+ rightFountain.Shoot(1);
+ break;
+ }
lastTrigger = Clock.CurrentTime;
}
diff --git a/osu.Game/Screens/Menu/StarFountain.cs b/osu.Game/Screens/Menu/StarFountain.cs
index 0d35f6e0e0..fd59ec3573 100644
--- a/osu.Game/Screens/Menu/StarFountain.cs
+++ b/osu.Game/Screens/Menu/StarFountain.cs
@@ -23,7 +23,7 @@ namespace osu.Game.Screens.Menu
InternalChild = spewer = new StarFountainSpewer();
}
- public void Shoot() => spewer.Shoot();
+ public void Shoot(int direction) => spewer.Shoot(direction);
protected override void SkinChanged(ISkinSource skin)
{
@@ -81,10 +81,10 @@ namespace osu.Game.Screens.Menu
return lastShootDirection * x_velocity_from_direction * (float)(1 - 2 * (Clock.CurrentTime - lastShootTime!.Value) / shoot_duration) + getRandomVariance(x_velocity_random_variance);
}
- public void Shoot()
+ public void Shoot(int direction)
{
lastShootTime = Clock.CurrentTime;
- lastShootDirection = RNG.Next(-1, 2);
+ lastShootDirection = direction;
}
private static float getRandomVariance(float variance) => RNG.NextSingle(-variance, variance);
diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs
index 05232fe0e2..916b799d50 100644
--- a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs
+++ b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs
@@ -113,7 +113,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components
protected partial class Section : Container
{
- private readonly Container content;
+ private readonly ReverseChildIDFillFlowContainer content;
protected override Container Content => content;
@@ -135,10 +135,11 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components
Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 12),
Text = title.ToUpperInvariant(),
},
- content = new Container
+ content = new ReverseChildIDFillFlowContainer
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
+ Direction = FillDirection.Vertical
},
},
};
diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs
index e93f56c2e2..84e419d67a 100644
--- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs
+++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs
@@ -23,6 +23,7 @@ using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Match.Components;
using osuTK;
+using osu.Game.Localisation;
namespace osu.Game.Screens.OnlinePlay.Playlists
{
@@ -80,6 +81,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
private IBindable localUser = null!;
private readonly Room room;
+ private OsuSpriteText durationNoticeText = null!;
public MatchSettings(Room room)
{
@@ -141,14 +143,22 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
},
new Section("Duration")
{
- Child = new Container
+ Children = new Drawable[]
{
- RelativeSizeAxes = Axes.X,
- Height = 40,
- Child = DurationField = new DurationDropdown
+ new Container
{
- RelativeSizeAxes = Axes.X
- }
+ RelativeSizeAxes = Axes.X,
+ Height = 40,
+ Child = DurationField = new DurationDropdown
+ {
+ RelativeSizeAxes = Axes.X
+ },
+ },
+ durationNoticeText = new OsuSpriteText
+ {
+ Alpha = 0,
+ Colour = colours.Yellow,
+ },
}
},
new Section("Allowed attempts (across all playlist items)")
@@ -305,6 +315,17 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
MaxAttempts.BindValueChanged(count => MaxAttemptsField.Text = count.NewValue?.ToString(), true);
Duration.BindValueChanged(duration => DurationField.Current.Value = duration.NewValue ?? TimeSpan.FromMinutes(30), true);
+ DurationField.Current.BindValueChanged(duration =>
+ {
+ if (hasValidDuration)
+ durationNoticeText.Hide();
+ else
+ {
+ durationNoticeText.Show();
+ durationNoticeText.Text = OnlinePlayStrings.SupporterOnlyDurationNotice;
+ }
+ });
+
localUser = api.LocalUser.GetBoundCopy();
localUser.BindValueChanged(populateDurations, true);
@@ -314,6 +335,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
private void populateDurations(ValueChangedEvent user)
{
+ // roughly correct (see https://github.com/Humanizr/Humanizer/blob/18167e56c082449cc4fe805b8429e3127a7b7f93/readme.md?plain=1#L427)
+ // if we want this to be more accurate we might consider sending an actual end time, not a time span. probably not required though.
+ const int days_in_month = 31;
+
DurationField.Items = new[]
{
TimeSpan.FromMinutes(30),
@@ -326,18 +351,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
TimeSpan.FromDays(3),
TimeSpan.FromDays(7),
TimeSpan.FromDays(14),
+ TimeSpan.FromDays(days_in_month),
+ TimeSpan.FromDays(days_in_month * 3),
};
-
- // TODO: show these in the interface at all times.
- if (user.NewValue.IsSupporter)
- {
- // roughly correct (see https://github.com/Humanizr/Humanizer/blob/18167e56c082449cc4fe805b8429e3127a7b7f93/readme.md?plain=1#L427)
- // if we want this to be more accurate we might consider sending an actual end time, not a time span. probably not required though.
- const int days_in_month = 31;
-
- DurationField.AddDropdownItem(TimeSpan.FromDays(days_in_month));
- DurationField.AddDropdownItem(TimeSpan.FromDays(days_in_month * 3));
- }
}
protected override void Update()
@@ -352,7 +368,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
private void onPlaylistChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
playlistLength.Text = $"Length: {Playlist.GetTotalDuration()}";
- private bool hasValidSettings => RoomID.Value == null && NameField.Text.Length > 0 && Playlist.Count > 0;
+ private bool hasValidSettings => RoomID.Value == null && NameField.Text.Length > 0 && Playlist.Count > 0
+ && hasValidDuration;
+
+ private bool hasValidDuration => DurationField.Current.Value <= TimeSpan.FromDays(14) || localUser.Value.IsSupporter;
private void apply()
{
diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs
index ba392386de..61b0dc5aa1 100644
--- a/osu.Game/Skinning/ArgonSkin.cs
+++ b/osu.Game/Skinning/ArgonSkin.cs
@@ -232,6 +232,6 @@ namespace osu.Game.Skinning
}
private static Color4 getComboColour(IHasComboColours source, int colourIndex)
- => source.ComboColours[colourIndex % source.ComboColours.Count];
+ => source.ComboColours![colourIndex % source.ComboColours.Count];
}
}
diff --git a/osu.Game/Skinning/TrianglesSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs
index a68a7fd5b9..0957f60579 100644
--- a/osu.Game/Skinning/TrianglesSkin.cs
+++ b/osu.Game/Skinning/TrianglesSkin.cs
@@ -203,6 +203,6 @@ namespace osu.Game.Skinning
}
private static Color4 getComboColour(IHasComboColours source, int colourIndex)
- => source.ComboColours[colourIndex % source.ComboColours.Count];
+ => source.ComboColours![colourIndex % source.ComboColours.Count];
}
}
diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs
index 37260b3b13..ffe40243ab 100644
--- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs
+++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs
@@ -57,7 +57,13 @@ namespace osu.Game.Tests.Visual
}
if (CreateNestedActionContainer)
- mainContent.Add(new GlobalActionContainer(null));
+ {
+ var globalActionContainer = new GlobalActionContainer(null)
+ {
+ Child = mainContent
+ };
+ mainContent = globalActionContainer;
+ }
base.Content.AddRange(new Drawable[]
{
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index a5a9387e36..4c3205178a 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -36,8 +36,8 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
diff --git a/osu.iOS.props b/osu.iOS.props
index d93dfaf67c..fd1ba0f6d1 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -23,6 +23,6 @@
iossimulator-x64
-
+