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.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
index 06c825e37d..a24fcaad8d 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
@@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
{
private const double individual_decay_base = 0.125;
private const double overall_decay_base = 0.30;
- private const double release_threshold = 24;
+ private const double release_threshold = 30;
protected override double SkillMultiplier => 1;
protected override double StrainDecayBase => 1;
@@ -50,10 +50,13 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
for (int i = 0; i < endTimes.Length; ++i)
{
// The current note is overlapped if a previous note or end is overlapping the current note body
- isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) && Precision.DefinitelyBigger(endTime, endTimes[i], 1);
+ isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) &&
+ Precision.DefinitelyBigger(endTime, endTimes[i], 1) &&
+ Precision.DefinitelyBigger(startTime, startTimes[i], 1);
// We give a slight bonus to everything if something is held meanwhile
- if (Precision.DefinitelyBigger(endTimes[i], endTime, 1))
+ if (Precision.DefinitelyBigger(endTimes[i], endTime, 1) &&
+ Precision.DefinitelyBigger(startTime, startTimes[i], 1))
holdFactor = 1.25;
closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i]));
@@ -70,7 +73,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
// 0.0 +--------+-+---------------> Release Difference / ms
// release_threshold
if (isOverlapping)
- holdAddition = 1 / (1 + Math.Exp(0.5 * (release_threshold - closestEndTime)));
+ holdAddition = 1 / (1 + Math.Exp(0.27 * (release_threshold - closestEndTime)));
// Decay and increase individualStrains in own column
individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base);
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/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.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/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/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/UserInterface/TestSceneSliderWithTextBoxInput.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
new file mode 100644
index 0000000000..d23fcebae3
--- /dev/null
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs
@@ -0,0 +1,130 @@
+// 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.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Testing;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Graphics.UserInterfaceV2;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.UserInterface
+{
+ public partial class TestSceneSliderWithTextBoxInput : OsuManualInputManagerTestScene
+ {
+ private SliderWithTextBoxInput sliderWithTextBoxInput = null!;
+
+ private OsuSliderBar slider => sliderWithTextBoxInput.ChildrenOfType>().Single();
+ private Nub nub => sliderWithTextBoxInput.ChildrenOfType().Single();
+ private OsuTextBox textBox => sliderWithTextBoxInput.ChildrenOfType().Single();
+
+ [SetUpSteps]
+ public void SetUpSteps()
+ {
+ AddStep("create slider", () => Child = sliderWithTextBoxInput = new SliderWithTextBoxInput("Test Slider")
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Width = 0.5f,
+ Current = new BindableFloat
+ {
+ MinValue = -5,
+ MaxValue = 5,
+ Precision = 0.2f
+ }
+ });
+ }
+
+ [Test]
+ public void TestNonInstantaneousMode()
+ {
+ AddStep("set instantaneous to false", () => sliderWithTextBoxInput.Instantaneous = false);
+
+ AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+ AddStep("change text", () => textBox.Text = "3");
+ AddAssert("slider not moved", () => slider.Current.Value, () => Is.Zero);
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.Zero);
+
+ AddStep("commit text", () => InputManager.Key(Key.Enter));
+ AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3));
+ AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3));
+
+ AddStep("move mouse to nub", () => InputManager.MoveMouseTo(nub));
+ AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left));
+ AddStep("move mouse to minimum", () => InputManager.MoveMouseTo(sliderWithTextBoxInput.ScreenSpaceDrawQuad.BottomLeft));
+ AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("3"));
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3));
+
+ AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left));
+ AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5"));
+ AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+ AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+ AddStep("set text to invalid", () => textBox.Text = "garbage");
+ AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+ AddStep("commit text", () => InputManager.Key(Key.Enter));
+ AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5"));
+ AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+ AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+ AddStep("set text to invalid", () => textBox.Text = "garbage");
+ AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+ AddStep("lose focus", () => InputManager.ChangeFocus(null));
+ AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5"));
+ AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+ }
+
+ [Test]
+ public void TestInstantaneousMode()
+ {
+ AddStep("set instantaneous to true", () => sliderWithTextBoxInput.Instantaneous = true);
+
+ AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+ AddStep("change text", () => textBox.Text = "3");
+ AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3));
+ AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3));
+
+ AddStep("commit text", () => InputManager.Key(Key.Enter));
+ AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(3));
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3));
+
+ AddStep("move mouse to nub", () => InputManager.MoveMouseTo(nub));
+ AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left));
+ AddStep("move mouse to minimum", () => InputManager.MoveMouseTo(sliderWithTextBoxInput.ScreenSpaceDrawQuad.BottomLeft));
+ AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5"));
+ AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+ AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left));
+ AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("-5"));
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+ AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+ AddStep("set text to invalid", () => textBox.Text = "garbage");
+ AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+ AddStep("commit text", () => InputManager.Key(Key.Enter));
+ AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5"));
+ AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+ AddStep("focus textbox", () => InputManager.ChangeFocus(textBox));
+ AddStep("set text to invalid", () => textBox.Text = "garbage");
+ AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+
+ AddStep("lose focus", () => InputManager.ChangeFocus(null));
+ AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5"));
+ AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5));
+ AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5));
+ }
+ }
+}
diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs
index fa0cbda16b..3d060600f7 100644
--- a/osu.Game.Tournament/Components/SongBar.cs
+++ b/osu.Game.Tournament/Components/SongBar.cs
@@ -14,7 +14,6 @@ using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Rulesets;
using osu.Game.Screens.Menu;
-using osu.Game.Tournament.Models;
using osuTK;
using osuTK.Graphics;
@@ -22,14 +21,14 @@ namespace osu.Game.Tournament.Components
{
public partial class SongBar : CompositeDrawable
{
- private TournamentBeatmap? beatmap;
+ private IBeatmapInfo? beatmap;
public const float HEIGHT = 145 / 2f;
[Resolved]
private IBindable ruleset { get; set; } = null!;
- public TournamentBeatmap? Beatmap
+ public IBeatmapInfo? Beatmap
{
set
{
@@ -37,7 +36,7 @@ namespace osu.Game.Tournament.Components
return;
beatmap = value;
- update();
+ refreshContent();
}
}
@@ -49,7 +48,7 @@ namespace osu.Game.Tournament.Components
set
{
mods = value;
- update();
+ refreshContent();
}
}
@@ -71,19 +70,25 @@ namespace osu.Game.Tournament.Components
protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false;
[BackgroundDependencyLoader]
- private void load()
+ private void load(OsuColour colours)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
+ Masking = true;
+ CornerRadius = 5;
+
InternalChildren = new Drawable[]
{
+ new Box
+ {
+ Colour = colours.Gray3,
+ RelativeSizeAxes = Axes.Both,
+ },
flow = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
- LayoutDuration = 500,
- LayoutEasing = Easing.OutQuint,
Direction = FillDirection.Full,
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
@@ -93,7 +98,7 @@ namespace osu.Game.Tournament.Components
Expanded = true;
}
- private void update()
+ private void refreshContent()
{
if (beatmap == null)
{
@@ -229,7 +234,7 @@ namespace osu.Game.Tournament.Components
}
}
},
- new TournamentBeatmapPanel(beatmap)
+ new UnmaskedTournamentBeatmapPanel(beatmap)
{
RelativeSizeAxes = Axes.X,
Width = 0.5f,
@@ -272,4 +277,18 @@ 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/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs
index ba922c7c7b..4e0adb30ac 100644
--- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs
+++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs
@@ -20,7 +20,7 @@ namespace osu.Game.Tournament.Components
{
public partial class TournamentBeatmapPanel : CompositeDrawable
{
- public readonly TournamentBeatmap? Beatmap;
+ public readonly IBeatmapInfo? Beatmap;
private readonly string mod;
@@ -30,7 +30,7 @@ namespace osu.Game.Tournament.Components
private Box flash = null!;
- public TournamentBeatmapPanel(TournamentBeatmap? beatmap, string mod = "")
+ public TournamentBeatmapPanel(IBeatmapInfo? beatmap, string mod = "")
{
Beatmap = beatmap;
this.mod = mod;
@@ -58,7 +58,7 @@ namespace osu.Game.Tournament.Components
{
RelativeSizeAxes = Axes.Both,
Colour = OsuColour.Gray(0.5f),
- OnlineInfo = Beatmap,
+ OnlineInfo = (Beatmap as IBeatmapSetOnlineInfo),
},
new FillFlowContainer
{
diff --git a/osu.Game.Tournament/IPC/MatchIPCInfo.cs b/osu.Game.Tournament/IPC/MatchIPCInfo.cs
index f57518971f..b4575144e7 100644
--- a/osu.Game.Tournament/IPC/MatchIPCInfo.cs
+++ b/osu.Game.Tournament/IPC/MatchIPCInfo.cs
@@ -14,7 +14,7 @@ namespace osu.Game.Tournament.IPC
public Bindable Mods { get; } = new Bindable();
public Bindable State { get; } = new Bindable();
public Bindable ChatChannel { get; } = new Bindable();
- public BindableInt Score1 { get; } = new BindableInt();
- public BindableInt Score2 { get; } = new BindableInt();
+ public BindableLong Score1 { get; } = new BindableLong();
+ public BindableLong Score2 { get; } = new BindableLong();
}
}
diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs
index 7ae20acc77..f8de34a511 100644
--- a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs
+++ b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs
@@ -1,181 +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 System;
using osu.Framework.Allocation;
-using osu.Framework.Bindables;
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Framework.Graphics.Shapes;
-using osu.Game.Graphics;
-using osu.Game.Graphics.Sprites;
-using osu.Game.Graphics.UserInterface;
+using osu.Game.Screens.Play.HUD;
using osu.Game.Tournament.IPC;
-using osuTK;
namespace osu.Game.Tournament.Screens.Gameplay.Components
{
- // TODO: Update to derive from osu-side class?
- public partial class TournamentMatchScoreDisplay : CompositeDrawable
+ public partial class TournamentMatchScoreDisplay : MatchScoreDisplay
{
- private const float bar_height = 18;
-
- private readonly BindableInt score1 = new BindableInt();
- private readonly BindableInt score2 = new BindableInt();
-
- private readonly MatchScoreCounter score1Text;
- private readonly MatchScoreCounter score2Text;
-
- private readonly MatchScoreDiffCounter scoreDiffText;
-
- private readonly Drawable score1Bar;
- private readonly Drawable score2Bar;
-
- public TournamentMatchScoreDisplay()
- {
- RelativeSizeAxes = Axes.X;
- AutoSizeAxes = Axes.Y;
-
- InternalChildren = new[]
- {
- new Box
- {
- Name = "top bar red (static)",
- RelativeSizeAxes = Axes.X,
- Height = bar_height / 4,
- Width = 0.5f,
- Colour = TournamentGame.COLOUR_RED,
- Anchor = Anchor.TopCentre,
- Origin = Anchor.TopRight
- },
- new Box
- {
- Name = "top bar blue (static)",
- RelativeSizeAxes = Axes.X,
- Height = bar_height / 4,
- Width = 0.5f,
- Colour = TournamentGame.COLOUR_BLUE,
- Anchor = Anchor.TopCentre,
- Origin = Anchor.TopLeft
- },
- score1Bar = new Box
- {
- Name = "top bar red",
- RelativeSizeAxes = Axes.X,
- Height = bar_height,
- Width = 0,
- Colour = TournamentGame.COLOUR_RED,
- Anchor = Anchor.TopCentre,
- Origin = Anchor.TopRight
- },
- score1Text = new MatchScoreCounter
- {
- Anchor = Anchor.TopCentre,
- Origin = Anchor.TopCentre
- },
- score2Bar = new Box
- {
- Name = "top bar blue",
- RelativeSizeAxes = Axes.X,
- Height = bar_height,
- Width = 0,
- Colour = TournamentGame.COLOUR_BLUE,
- Anchor = Anchor.TopCentre,
- Origin = Anchor.TopLeft
- },
- score2Text = new MatchScoreCounter
- {
- Anchor = Anchor.TopCentre,
- Origin = Anchor.TopCentre
- },
- scoreDiffText = new MatchScoreDiffCounter
- {
- Anchor = Anchor.TopCentre,
- Margin = new MarginPadding
- {
- Top = bar_height / 4,
- Horizontal = 8
- },
- Alpha = 0
- }
- };
- }
-
[BackgroundDependencyLoader]
private void load(MatchIPCInfo ipc)
{
- score1.BindValueChanged(_ => updateScores());
- score1.BindTo(ipc.Score1);
-
- score2.BindValueChanged(_ => updateScores());
- score2.BindTo(ipc.Score2);
- }
-
- private void updateScores()
- {
- score1Text.Current.Value = score1.Value;
- score2Text.Current.Value = score2.Value;
-
- var winningText = score1.Value > score2.Value ? score1Text : score2Text;
- var losingText = score1.Value <= score2.Value ? score1Text : score2Text;
-
- winningText.Winning = true;
- losingText.Winning = false;
-
- var winningBar = score1.Value > score2.Value ? score1Bar : score2Bar;
- var losingBar = score1.Value <= score2.Value ? score1Bar : score2Bar;
-
- int diff = Math.Max(score1.Value, score2.Value) - Math.Min(score1.Value, score2.Value);
-
- losingBar.ResizeWidthTo(0, 400, Easing.OutQuint);
- winningBar.ResizeWidthTo(Math.Min(0.4f, MathF.Pow(diff / 1500000f, 0.5f) / 2), 400, Easing.OutQuint);
-
- scoreDiffText.Alpha = diff != 0 ? 1 : 0;
- scoreDiffText.Current.Value = -diff;
- scoreDiffText.Origin = score1.Value > score2.Value ? Anchor.TopLeft : Anchor.TopRight;
- }
-
- protected override void UpdateAfterChildren()
- {
- base.UpdateAfterChildren();
- score1Text.X = -Math.Max(5 + score1Text.DrawWidth / 2, score1Bar.DrawWidth);
- score2Text.X = Math.Max(5 + score2Text.DrawWidth / 2, score2Bar.DrawWidth);
- }
-
- private partial class MatchScoreCounter : CommaSeparatedScoreCounter
- {
- private OsuSpriteText displayedSpriteText = null!;
-
- public MatchScoreCounter()
- {
- Margin = new MarginPadding { Top = bar_height, Horizontal = 10 };
- }
-
- public bool Winning
- {
- set => updateFont(value);
- }
-
- protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s =>
- {
- displayedSpriteText = s;
- displayedSpriteText.Spacing = new Vector2(-6);
- updateFont(false);
- });
-
- private void updateFont(bool winning)
- => displayedSpriteText.Font = winning
- ? OsuFont.Torus.With(weight: FontWeight.Bold, size: 50, fixedWidth: true)
- : OsuFont.Torus.With(weight: FontWeight.Regular, size: 40, fixedWidth: true);
- }
-
- private partial class MatchScoreDiffCounter : CommaSeparatedScoreCounter
- {
- protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s =>
- {
- s.Spacing = new Vector2(-2);
- s.Font = OsuFont.Torus.With(weight: FontWeight.Regular, size: bar_height, fixedWidth: true);
- });
+ Team1Score.BindTo(ipc.Score1);
+ Team2Score.BindTo(ipc.Score2);
}
}
}
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/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/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
new file mode 100644
index 0000000000..37ea2a3f96
--- /dev/null
+++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
@@ -0,0 +1,145 @@
+// 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.Globalization;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Localisation;
+using osu.Game.Overlays.Settings;
+using osu.Game.Utils;
+using osuTK;
+
+namespace osu.Game.Graphics.UserInterfaceV2
+{
+ public partial class SliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue
+ where T : struct, IEquatable, IComparable, IConvertible
+ {
+ ///
+ /// A custom step value for each key press which actuates a change on this control.
+ ///
+ public float KeyboardStep
+ {
+ get => slider.KeyboardStep;
+ set => slider.KeyboardStep = value;
+ }
+
+ public Bindable Current
+ {
+ get => slider.Current;
+ set => slider.Current = value;
+ }
+
+ private bool instantaneous;
+
+ ///
+ /// Whether changes to the slider should instantaneously transfer to the text box (and vice versa).
+ /// If , the transfer will happen on text box commit (explicit, or implicit via focus loss), or on slider drag end.
+ ///
+ public bool Instantaneous
+ {
+ get => instantaneous;
+ set
+ {
+ instantaneous = value;
+ slider.TransferValueOnCommit = !instantaneous;
+ }
+ }
+
+ private readonly SettingsSlider slider;
+ private readonly LabelledTextBox textBox;
+
+ public SliderWithTextBoxInput(LocalisableString labelText)
+ {
+ RelativeSizeAxes = Axes.X;
+ AutoSizeAxes = Axes.Y;
+
+ InternalChildren = new Drawable[]
+ {
+ new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(20),
+ Children = new Drawable[]
+ {
+ textBox = new LabelledTextBox
+ {
+ Label = labelText,
+ },
+ slider = new SettingsSlider
+ {
+ TransferValueOnCommit = true,
+ RelativeSizeAxes = Axes.X,
+ }
+ }
+ },
+ };
+
+ textBox.OnCommit += textCommitted;
+ textBox.Current.BindValueChanged(textChanged);
+
+ Current.BindValueChanged(updateTextBoxFromSlider, true);
+ }
+
+ public bool TakeFocus() => GetContainingInputManager().ChangeFocus(textBox);
+
+ private bool updatingFromTextBox;
+
+ private void textChanged(ValueChangedEvent change)
+ {
+ if (!instantaneous) return;
+
+ tryUpdateSliderFromTextBox();
+ }
+
+ private void textCommitted(TextBox t, bool isNew)
+ {
+ tryUpdateSliderFromTextBox();
+
+ // If the attempted update above failed, restore text box to match the slider.
+ Current.TriggerChange();
+ }
+
+ private void tryUpdateSliderFromTextBox()
+ {
+ updatingFromTextBox = true;
+
+ try
+ {
+ switch (slider.Current)
+ {
+ case Bindable bindableInt:
+ bindableInt.Value = int.Parse(textBox.Current.Value);
+ break;
+
+ case Bindable bindableDouble:
+ bindableDouble.Value = double.Parse(textBox.Current.Value);
+ break;
+
+ default:
+ slider.Current.Parse(textBox.Current.Value);
+ break;
+ }
+ }
+ catch
+ {
+ // ignore parsing failures.
+ // sane state will eventually be restored by a commit (either explicit, or implicit via focus loss).
+ }
+
+ updatingFromTextBox = false;
+ }
+
+ private void updateTextBoxFromSlider(ValueChangedEvent _)
+ {
+ if (updatingFromTextBox) return;
+
+ decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo);
+ textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}");
+ }
+ }
+}
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..9a0a2d5c15 100644
--- a/osu.Game/Input/Bindings/GlobalActionContainer.cs
+++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs
@@ -105,6 +105,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[]
@@ -378,5 +379,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/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/Edit/Timing/ControlPointList.cs b/osu.Game/Screens/Edit/Timing/ControlPointList.cs
index 555c36aac0..22e37b9efb 100644
--- a/osu.Game/Screens/Edit/Timing/ControlPointList.cs
+++ b/osu.Game/Screens/Edit/Timing/ControlPointList.cs
@@ -147,13 +147,25 @@ namespace osu.Game.Screens.Edit.Timing
trackedType = null;
else
{
- // If the selected group only has one control point, update the tracking type.
- if (selectedGroup.Value.ControlPoints.Count == 1)
- trackedType = selectedGroup.Value?.ControlPoints.Single().GetType();
- // If the selected group has more than one control point, choose the first as the tracking type
- // if we don't already have a singular tracked type.
- else if (trackedType == null)
- trackedType = selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType();
+ switch (selectedGroup.Value.ControlPoints.Count)
+ {
+ // If the selected group has no control points, clear the tracked type.
+ // Otherwise the user will be unable to select a group with no control points.
+ case 0:
+ trackedType = null;
+ break;
+
+ // If the selected group only has one control point, update the tracking type.
+ case 1:
+ trackedType = selectedGroup.Value?.ControlPoints.Single().GetType();
+ break;
+
+ // If the selected group has more than one control point, choose the first as the tracking type
+ // if we don't already have a singular tracked type.
+ default:
+ trackedType ??= selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType();
+ break;
+ }
}
if (trackedType != null)
diff --git a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs
deleted file mode 100644
index 1bf0e5299d..0000000000
--- a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs
+++ /dev/null
@@ -1,106 +0,0 @@
-// 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.Globalization;
-using osu.Framework.Bindables;
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Framework.Graphics.UserInterface;
-using osu.Framework.Localisation;
-using osu.Game.Graphics.UserInterfaceV2;
-using osu.Game.Overlays.Settings;
-using osu.Game.Utils;
-using osuTK;
-
-namespace osu.Game.Screens.Edit.Timing
-{
- public partial class SliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue
- where T : struct, IEquatable, IComparable, IConvertible
- {
- private readonly SettingsSlider slider;
-
- public SliderWithTextBoxInput(LocalisableString labelText)
- {
- LabelledTextBox textBox;
-
- RelativeSizeAxes = Axes.X;
- AutoSizeAxes = Axes.Y;
-
- InternalChildren = new Drawable[]
- {
- new FillFlowContainer
- {
- RelativeSizeAxes = Axes.X,
- AutoSizeAxes = Axes.Y,
- Direction = FillDirection.Vertical,
- Spacing = new Vector2(20),
- Children = new Drawable[]
- {
- textBox = new LabelledTextBox
- {
- Label = labelText,
- },
- slider = new SettingsSlider
- {
- TransferValueOnCommit = true,
- RelativeSizeAxes = Axes.X,
- }
- }
- },
- };
-
- textBox.OnCommit += (t, isNew) =>
- {
- if (!isNew) return;
-
- try
- {
- switch (slider.Current)
- {
- case Bindable bindableInt:
- bindableInt.Value = int.Parse(t.Text);
- break;
-
- case Bindable bindableDouble:
- bindableDouble.Value = double.Parse(t.Text);
- break;
-
- default:
- slider.Current.Parse(t.Text);
- break;
- }
- }
- catch
- {
- // TriggerChange below will restore the previous text value on failure.
- }
-
- // This is run regardless of parsing success as the parsed number may not actually trigger a change
- // due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state.
- Current.TriggerChange();
- };
-
- Current.BindValueChanged(_ =>
- {
- decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo);
- textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}");
- }, true);
- }
-
- ///
- /// A custom step value for each key press which actuates a change on this control.
- ///
- public float KeyboardStep
- {
- get => slider.KeyboardStep;
- set => slider.KeyboardStep = value;
- }
-
- public Bindable Current
- {
- get => slider.Current;
- set => slider.Current = value;
- }
- }
-}
diff --git a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs
index 58bf4eea4b..4a61c7fd1b 100644
--- a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs
+++ b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.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 osu.Framework.Allocation;
using osu.Framework.Bindables;
@@ -24,11 +22,13 @@ namespace osu.Game.Screens.Play.HUD
public BindableLong Team1Score = new BindableLong();
public BindableLong Team2Score = new BindableLong();
- protected MatchScoreCounter Score1Text;
- protected MatchScoreCounter Score2Text;
+ protected MatchScoreCounter Score1Text = null!;
+ protected MatchScoreCounter Score2Text = null!;
- private Drawable score1Bar;
- private Drawable score2Bar;
+ private Drawable score1Bar = null!;
+ private Drawable score2Bar = null!;
+
+ private MatchScoreDiffCounter scoreDiffText = null!;
[BackgroundDependencyLoader]
private void load(OsuColour colours)
@@ -98,6 +98,16 @@ namespace osu.Game.Screens.Play.HUD
},
}
},
+ scoreDiffText = new MatchScoreDiffCounter
+ {
+ Anchor = Anchor.TopCentre,
+ Margin = new MarginPadding
+ {
+ Top = bar_height / 4,
+ Horizontal = 8
+ },
+ Alpha = 0
+ }
};
}
@@ -139,6 +149,10 @@ namespace osu.Game.Screens.Play.HUD
losingBar.ResizeWidthTo(0, 400, Easing.OutQuint);
winningBar.ResizeWidthTo(Math.Min(0.4f, MathF.Pow(diff / 1500000f, 0.5f) / 2), 400, Easing.OutQuint);
+
+ scoreDiffText.Alpha = diff != 0 ? 1 : 0;
+ scoreDiffText.Current.Value = -diff;
+ scoreDiffText.Origin = Team1Score.Value > Team2Score.Value ? Anchor.TopLeft : Anchor.TopRight;
}
protected override void UpdateAfterChildren()
@@ -150,7 +164,7 @@ namespace osu.Game.Screens.Play.HUD
protected partial class MatchScoreCounter : CommaSeparatedScoreCounter
{
- private OsuSpriteText displayedSpriteText;
+ private OsuSpriteText displayedSpriteText = null!;
public MatchScoreCounter()
{
@@ -174,5 +188,14 @@ namespace osu.Game.Screens.Play.HUD
? OsuFont.Torus.With(weight: FontWeight.Bold, size: font_size, fixedWidth: true)
: OsuFont.Torus.With(weight: FontWeight.Regular, size: font_size * 0.8f, fixedWidth: true);
}
+
+ private partial class MatchScoreDiffCounter : CommaSeparatedScoreCounter
+ {
+ protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s =>
+ {
+ s.Spacing = new Vector2(-2);
+ s.Font = OsuFont.Torus.With(weight: FontWeight.Regular, size: bar_height, fixedWidth: true);
+ });
+ }
}
}
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index a5a9387e36..7d4a721c91 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -36,7 +36,7 @@
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
-
+