diff --git a/CodeAnalysis/osu.globalconfig b/CodeAnalysis/osu.globalconfig
index 247a825033..8012c31eca 100644
--- a/CodeAnalysis/osu.globalconfig
+++ b/CodeAnalysis/osu.globalconfig
@@ -51,8 +51,11 @@ dotnet_diagnostic.IDE1006.severity = warning
# Too many noisy warnings for parsing/formatting numbers
dotnet_diagnostic.CA1305.severity = none
+# messagepack complains about "osu" not being title cased due to reserved words
+dotnet_diagnostic.CS8981.severity = none
+
# CA1507: Use nameof to express symbol names
-# Flaggs serialization name attributes
+# Flags serialization name attributes
dotnet_diagnostic.CA1507.severity = suggestion
# CA1806: Do not ignore method results
diff --git a/README.md b/README.md
index 6043497181..32c43995f4 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ You can also generally download a version for your current device from the [osu!
If your platform is unsupported or not listed above, there is still a chance you can run the release or manually build it by following the instructions below.
-**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores in early 2024.
+**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores very soon to se don't have to live with this limitation.
## Developing a custom ruleset
diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
index f77cda1533..1d368e9bd1 100644
--- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
+++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
@@ -9,9 +9,9 @@
false
-
+
-
+
diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs
index 9cd18d2d9f..0699f5d039 100644
--- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs
+++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs
@@ -14,7 +14,16 @@ namespace osu.Game.Rulesets.EmptyFreeform.Objects
public Vector2 Position { get; set; }
- public float X => Position.X;
- public float Y => Position.Y;
+ public float X
+ {
+ get => Position.X;
+ set => Position = new Vector2(value, Y);
+ }
+
+ public float Y
+ {
+ get => Position.Y;
+ set => Position = new Vector2(X, value);
+ }
}
}
diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
index 47cabaddb1..d69bc78b8f 100644
--- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
+++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
@@ -9,9 +9,9 @@
false
-
+
-
+
diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs
index 0c22554e82..f938d26b26 100644
--- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs
+++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs
@@ -14,7 +14,16 @@ namespace osu.Game.Rulesets.Pippidon.Objects
public Vector2 Position { get; set; }
- public float X => Position.X;
- public float Y => Position.Y;
+ public float X
+ {
+ get => Position.X;
+ set => Position = new Vector2(value, Y);
+ }
+
+ public float Y
+ {
+ get => Position.Y;
+ set => Position = new Vector2(X, value);
+ }
}
}
diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
index a7d62291d0..7ac269f65f 100644
--- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
+++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
@@ -9,9 +9,9 @@
false
-
+
-
+
diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
index 47cabaddb1..d69bc78b8f 100644
--- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
+++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
@@ -9,9 +9,9 @@
false
-
+
-
+
diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs
index 2d3f4e0ed6..c33608832f 100644
--- a/osu.Desktop/OsuGameDesktop.cs
+++ b/osu.Desktop/OsuGameDesktop.cs
@@ -67,7 +67,12 @@ namespace osu.Desktop
{
try
{
- stableInstallPath = getStableInstallPathFromRegistry();
+ stableInstallPath = getStableInstallPathFromRegistry("osustable.File.osz");
+
+ if (!string.IsNullOrEmpty(stableInstallPath) && checkExists(stableInstallPath))
+ return stableInstallPath;
+
+ stableInstallPath = getStableInstallPathFromRegistry("osu!");
if (!string.IsNullOrEmpty(stableInstallPath) && checkExists(stableInstallPath))
return stableInstallPath;
@@ -89,9 +94,9 @@ namespace osu.Desktop
}
[SupportedOSPlatform("windows")]
- private string? getStableInstallPathFromRegistry()
+ private string? getStableInstallPathFromRegistry(string progId)
{
- using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey("osu!"))
+ using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey(progId))
return key?.OpenSubKey(WindowsAssociationManager.SHELL_OPEN_COMMAND)?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", "");
}
diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs
index 6f53c65ca9..4a5fc6218e 100644
--- a/osu.Desktop/Windows/WindowsAssociationManager.cs
+++ b/osu.Desktop/Windows/WindowsAssociationManager.cs
@@ -17,6 +17,7 @@ namespace osu.Desktop.Windows
public static class WindowsAssociationManager
{
private const string software_classes = @"Software\Classes";
+ private const string software_registered_applications = @"Software\RegisteredApplications";
///
/// Sub key for setting the icon.
@@ -36,7 +37,11 @@ namespace osu.Desktop.Windows
/// Program ID prefix used for file associations. Should be relatively short since the full program ID has a 39 character limit,
/// see https://learn.microsoft.com/en-us/windows/win32/com/-progid--key.
///
- private const string program_id_prefix = "osu.File";
+ private const string program_id_file_prefix = "osu.File";
+
+ private const string program_id_protocol_prefix = "osu.Uri";
+
+ private static readonly ApplicationCapability application_capability = new ApplicationCapability(@"osu", @"Software\ppy\osu\Capabilities", "osu!(lazer)");
private static readonly FileAssociation[] file_associations =
{
@@ -56,14 +61,13 @@ namespace osu.Desktop.Windows
/// Installs file and URI associations.
///
///
- /// Call in a timely fashion to keep descriptions up-to-date and localised.
+ /// Call in a timely fashion to keep descriptions up-to-date and localised.
///
public static void InstallAssociations()
{
try
{
updateAssociations();
- updateDescriptions(null); // write default descriptions in case `UpdateDescriptions()` is not called.
NotifyShellUpdate();
}
catch (Exception e)
@@ -76,17 +80,13 @@ namespace osu.Desktop.Windows
/// Updates associations with latest definitions.
///
///
- /// Call in a timely fashion to keep descriptions up-to-date and localised.
+ /// Call in a timely fashion to keep descriptions up-to-date and localised.
///
public static void UpdateAssociations()
{
try
{
updateAssociations();
-
- // TODO: Remove once UpdateDescriptions() is called as specified in the xmldoc.
- updateDescriptions(null); // always write default descriptions, in case of updating from an older version in which file associations were not implemented/installed
-
NotifyShellUpdate();
}
catch (Exception e)
@@ -95,11 +95,19 @@ namespace osu.Desktop.Windows
}
}
- public static void UpdateDescriptions(LocalisationManager localisationManager)
+ // TODO: call this sometime.
+ public static void LocaliseDescriptions(LocalisationManager localisationManager)
{
try
{
- updateDescriptions(localisationManager);
+ application_capability.LocaliseDescription(localisationManager);
+
+ foreach (var association in file_associations)
+ association.LocaliseDescription(localisationManager);
+
+ foreach (var association in uri_associations)
+ association.LocaliseDescription(localisationManager);
+
NotifyShellUpdate();
}
catch (Exception e)
@@ -112,6 +120,8 @@ namespace osu.Desktop.Windows
{
try
{
+ application_capability.Uninstall();
+
foreach (var association in file_associations)
association.Uninstall();
@@ -133,22 +143,16 @@ namespace osu.Desktop.Windows
///
private static void updateAssociations()
{
+ application_capability.Install();
+
foreach (var association in file_associations)
association.Install();
foreach (var association in uri_associations)
association.Install();
- }
- private static void updateDescriptions(LocalisationManager? localisation)
- {
- foreach (var association in file_associations)
- association.UpdateDescription(getLocalisedString(association.Description));
-
- foreach (var association in uri_associations)
- association.UpdateDescription(getLocalisedString(association.Description));
-
- string getLocalisedString(LocalisableString s) => localisation?.GetLocalisedString(s) ?? s.ToString();
+ application_capability.RegisterFileAssociations(file_associations);
+ application_capability.RegisterUriAssociations(uri_associations);
}
#region Native interop
@@ -174,9 +178,87 @@ namespace osu.Desktop.Windows
#endregion
- private record FileAssociation(string Extension, LocalisableString Description, string IconPath)
+ private class ApplicationCapability
{
- private string programId => $@"{program_id_prefix}{Extension}";
+ private string uniqueName { get; }
+ private string capabilityPath { get; }
+ private LocalisableString description { get; }
+
+ public ApplicationCapability(string uniqueName, string capabilityPath, LocalisableString description)
+ {
+ this.uniqueName = uniqueName;
+ this.capabilityPath = capabilityPath;
+ this.description = description;
+ }
+
+ ///
+ /// Registers an application capability according to
+ /// Registering an Application for Use with Default Programs.
+ ///
+ public void Install()
+ {
+ using (var capability = Registry.CurrentUser.CreateSubKey(capabilityPath))
+ {
+ capability.SetValue(@"ApplicationDescription", description.ToString());
+ }
+
+ using (var registeredApplications = Registry.CurrentUser.OpenSubKey(software_registered_applications, true))
+ registeredApplications?.SetValue(uniqueName, capabilityPath);
+ }
+
+ public void RegisterFileAssociations(FileAssociation[] associations)
+ {
+ using var capability = Registry.CurrentUser.OpenSubKey(capabilityPath, true);
+ if (capability == null) return;
+
+ using var fileAssociations = capability.CreateSubKey(@"FileAssociations");
+
+ foreach (var association in associations)
+ fileAssociations.SetValue(association.Extension, association.ProgramId);
+ }
+
+ public void RegisterUriAssociations(UriAssociation[] associations)
+ {
+ using var capability = Registry.CurrentUser.OpenSubKey(capabilityPath, true);
+ if (capability == null) return;
+
+ using var urlAssociations = capability.CreateSubKey(@"UrlAssociations");
+
+ foreach (var association in associations)
+ urlAssociations.SetValue(association.Protocol, association.ProgramId);
+ }
+
+ public void LocaliseDescription(LocalisationManager localisationManager)
+ {
+ using (var capability = Registry.CurrentUser.OpenSubKey(capabilityPath, true))
+ {
+ capability?.SetValue(@"ApplicationDescription", localisationManager.GetLocalisedString(description));
+ }
+ }
+
+ public void Uninstall()
+ {
+ using (var registeredApplications = Registry.CurrentUser.OpenSubKey(software_registered_applications, true))
+ registeredApplications?.DeleteValue(uniqueName, throwOnMissingValue: false);
+
+ Registry.CurrentUser.DeleteSubKeyTree(capabilityPath, throwOnMissingSubKey: false);
+ }
+ }
+
+ private class FileAssociation
+ {
+ public string ProgramId => $@"{program_id_file_prefix}{Extension}";
+
+ public string Extension { get; }
+ private LocalisableString description { get; }
+ private string iconPath { get; }
+
+ public FileAssociation(string extension, LocalisableString description, string iconPath)
+ {
+ Extension = extension;
+ this.description = description;
+ this.iconPath = iconPath;
+ }
///
/// Installs a file extension association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key
@@ -187,10 +269,12 @@ namespace osu.Desktop.Windows
if (classes == null) return;
// register a program id for the given extension
- using (var programKey = classes.CreateSubKey(programId))
+ using (var programKey = classes.CreateSubKey(ProgramId))
{
+ programKey.SetValue(null, description.ToString());
+
using (var defaultIconKey = programKey.CreateSubKey(default_icon))
- defaultIconKey.SetValue(null, IconPath);
+ defaultIconKey.SetValue(null, iconPath);
using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND))
openCommandKey.SetValue(null, $@"""{exe_path}"" ""%1""");
@@ -198,23 +282,25 @@ namespace osu.Desktop.Windows
using (var extensionKey = classes.CreateSubKey(Extension))
{
- // set ourselves as the default program
- extensionKey.SetValue(null, programId);
+ // Clear out our existing default ProgramID. Default programs in Windows are handled internally by Explorer,
+ // so having it here is just confusing and may override user preferences.
+ if (extensionKey.GetValue(null) is string s && s == ProgramId)
+ extensionKey.SetValue(null, string.Empty);
// add to the open with dialog
// https://learn.microsoft.com/en-us/windows/win32/shell/how-to-include-an-application-on-the-open-with-dialog-box
using (var openWithKey = extensionKey.CreateSubKey(@"OpenWithProgIds"))
- openWithKey.SetValue(programId, string.Empty);
+ openWithKey.SetValue(ProgramId, string.Empty);
}
}
- public void UpdateDescription(string description)
+ public void LocaliseDescription(LocalisationManager localisationManager)
{
using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true);
if (classes == null) return;
- using (var programKey = classes.OpenSubKey(programId, true))
- programKey?.SetValue(null, description);
+ using (var programKey = classes.OpenSubKey(ProgramId, true))
+ programKey?.SetValue(null, localisationManager.GetLocalisedString(description));
}
///
@@ -227,26 +313,34 @@ namespace osu.Desktop.Windows
using (var extensionKey = classes.OpenSubKey(Extension, true))
{
- // clear our default association so that Explorer doesn't show the raw programId to users
- // the null/(Default) entry is used for both ProdID association and as a fallback friendly name, for legacy reasons
- if (extensionKey?.GetValue(null) is string s && s == programId)
- extensionKey.SetValue(null, string.Empty);
-
using (var openWithKey = extensionKey?.CreateSubKey(@"OpenWithProgIds"))
- openWithKey?.DeleteValue(programId, throwOnMissingValue: false);
+ openWithKey?.DeleteValue(ProgramId, throwOnMissingValue: false);
}
- classes.DeleteSubKeyTree(programId, throwOnMissingSubKey: false);
+ classes.DeleteSubKeyTree(ProgramId, throwOnMissingSubKey: false);
}
}
- private record UriAssociation(string Protocol, LocalisableString Description, string IconPath)
+ private class UriAssociation
{
///
/// "The URL Protocol string value indicates that this key declares a custom pluggable protocol handler."
/// See https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85).
///
- public const string URL_PROTOCOL = @"URL Protocol";
+ private const string url_protocol = @"URL Protocol";
+
+ public string Protocol { get; }
+ private LocalisableString description { get; }
+ private string iconPath { get; }
+
+ public UriAssociation(string protocol, LocalisableString description, string iconPath)
+ {
+ Protocol = protocol;
+ this.description = description;
+ this.iconPath = iconPath;
+ }
+
+ public string ProgramId => $@"{program_id_protocol_prefix}.{Protocol}";
///
/// Registers an URI protocol handler in accordance with https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85).
@@ -258,29 +352,38 @@ namespace osu.Desktop.Windows
using (var protocolKey = classes.CreateSubKey(Protocol))
{
- protocolKey.SetValue(URL_PROTOCOL, string.Empty);
+ protocolKey.SetValue(null, $@"URL:{description}");
+ protocolKey.SetValue(url_protocol, string.Empty);
- using (var defaultIconKey = protocolKey.CreateSubKey(default_icon))
- defaultIconKey.SetValue(null, IconPath);
+ // clear out old data
+ protocolKey.DeleteSubKeyTree(default_icon, throwOnMissingSubKey: false);
+ protocolKey.DeleteSubKeyTree(@"Shell", throwOnMissingSubKey: false);
+ }
- using (var openCommandKey = protocolKey.CreateSubKey(SHELL_OPEN_COMMAND))
+ // register a program id for the given protocol
+ using (var programKey = classes.CreateSubKey(ProgramId))
+ {
+ using (var defaultIconKey = programKey.CreateSubKey(default_icon))
+ defaultIconKey.SetValue(null, iconPath);
+
+ using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND))
openCommandKey.SetValue(null, $@"""{exe_path}"" ""%1""");
}
}
- public void UpdateDescription(string description)
+ public void LocaliseDescription(LocalisationManager localisationManager)
{
using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true);
if (classes == null) return;
using (var protocolKey = classes.OpenSubKey(Protocol, true))
- protocolKey?.SetValue(null, $@"URL:{description}");
+ protocolKey?.SetValue(null, $@"URL:{localisationManager.GetLocalisedString(description)}");
}
public void Uninstall()
{
using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true);
- classes?.DeleteSubKeyTree(Protocol, throwOnMissingSubKey: false);
+ classes?.DeleteSubKeyTree(ProgramId, throwOnMissingSubKey: false);
}
}
}
diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj
index d06c4dd41b..21c570a7b2 100644
--- a/osu.Desktop/osu.Desktop.csproj
+++ b/osu.Desktop/osu.Desktop.csproj
@@ -24,9 +24,9 @@
-
+
-
+
diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj
index 9764c71493..8a353eb2f5 100644
--- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj
+++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs
index 7b665b1ff9..278c7b1bde 100644
--- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.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 System.Linq;
using NUnit.Framework;
@@ -21,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
public partial class TestSceneJuiceStreamSelectionBlueprint : CatchSelectionBlueprintTestScene
{
- private JuiceStream hitObject;
+ private JuiceStream hitObject = null!;
private readonly ManualClock manualClock = new ManualClock();
@@ -193,6 +191,17 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
addVertexCheckStep(1, 0, times[0], positions[0]);
}
+ [Test]
+ public void TestDeletingSecondVertexDeletesEntireJuiceStream()
+ {
+ double[] times = { 100, 400 };
+ float[] positions = { 100, 150 };
+ addBlueprintStep(times, positions);
+
+ addDeleteVertexSteps(times[1], positions[1]);
+ AddAssert("juice stream deleted", () => EditorBeatmap.HitObjects, () => Is.Empty);
+ }
+
[Test]
public void TestVertexResampling()
{
diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj
index b434d6aaf9..56ee208670 100644
--- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj
+++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj
@@ -1,9 +1,9 @@
-
+
-
+
WinExe
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs
index e626392234..6a671458f0 100644
--- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs
@@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
}));
}
- public void UpdateHitObjectFromPath(JuiceStream hitObject)
+ public virtual void UpdateHitObjectFromPath(JuiceStream hitObject)
{
// The SV setting may need to be changed for the current path.
var svBindable = hitObject.SliderVelocityMultiplierBindable;
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs
index b2ee43ba16..26b26641d3 100644
--- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs
@@ -138,5 +138,13 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
EditorBeatmap?.EndChange();
}
+
+ public override void UpdateHitObjectFromPath(JuiceStream hitObject)
+ {
+ base.UpdateHitObjectFromPath(hitObject);
+
+ if (hitObject.Path.ControlPoints.Count <= 1 || !hitObject.Path.HasValidLength)
+ EditorBeatmap?.Remove(hitObject);
+ }
}
}
diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs
index 7b57dac36e..21cc260462 100644
--- a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs
@@ -88,10 +88,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
switch (PlacementActive)
{
case PlacementState.Waiting:
- if (!(result.Time is double snappedTime)) return;
-
HitObject.OriginalX = ToLocalSpace(result.ScreenSpacePosition).X;
- HitObject.StartTime = snappedTime;
+ if (result.Time is double snappedTime)
+ HitObject.StartTime = snappedTime;
break;
case PlacementState.Active:
@@ -107,21 +106,13 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
Vector2 startPosition = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject);
editablePath.Position = nestedOutlineContainer.Position = scrollingPath.Position = startPosition;
- updateHitObjectFromPath();
- }
+ if (lastEditablePathId != editablePath.PathId)
+ editablePath.UpdateHitObjectFromPath(HitObject);
+ lastEditablePathId = editablePath.PathId;
- private void updateHitObjectFromPath()
- {
- if (lastEditablePathId == editablePath.PathId)
- return;
-
- editablePath.UpdateHitObjectFromPath(HitObject);
ApplyDefaultsToHitObject();
-
scrollingPath.UpdatePathFrom(HitObjectContainer, HitObject);
nestedOutlineContainer.UpdateNestedObjectsFrom(HitObjectContainer, HitObject);
-
- lastEditablePathId = editablePath.PathId;
}
private double positionToTime(float relativeYPosition)
diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs
index aae3369d40..e0d80e0e64 100644
--- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs
+++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs
@@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Catch.Edit
protected override Drawable CreateHitObjectInspector() => new CatchHitObjectInspector(DistanceSnapProvider);
- protected override IEnumerable CreateTernaryButtons()
+ protected override IEnumerable CreateTernaryButtons()
=> base.CreateTernaryButtons()
.Concat(DistanceSnapProvider.CreateTernaryButtons());
diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
index 329055b3dd..2018fd5ea9 100644
--- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
+++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
@@ -210,11 +210,27 @@ namespace osu.Game.Rulesets.Catch.Objects
///
public float LegacyConvertedY { get; set; } = DEFAULT_LEGACY_CONVERT_Y;
- float IHasXPosition.X => OriginalX;
+ float IHasXPosition.X
+ {
+ get => OriginalX;
+ set => OriginalX = value;
+ }
- float IHasYPosition.Y => LegacyConvertedY;
+ float IHasYPosition.Y
+ {
+ get => LegacyConvertedY;
+ set => LegacyConvertedY = value;
+ }
- Vector2 IHasPosition.Position => new Vector2(OriginalX, LegacyConvertedY);
+ Vector2 IHasPosition.Position
+ {
+ get => new Vector2(OriginalX, LegacyConvertedY);
+ set
+ {
+ ((IHasXPosition)this).X = value.X;
+ ((IHasYPosition)this).Y = value.Y;
+ }
+ }
#endregion
}
diff --git a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj
index e7abd47881..5e4bad279b 100644
--- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj
+++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj
@@ -1,9 +1,9 @@
-
+
-
+
WinExe
diff --git a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs
index 25ad6b997d..c8c8867bc6 100644
--- a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs
+++ b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs
@@ -25,7 +25,11 @@ namespace osu.Game.Rulesets.Mania.Objects
#region LegacyBeatmapEncoder
- float IHasXPosition.X => Column;
+ float IHasXPosition.X
+ {
+ get => Column;
+ set => Column = (int)value;
+ }
#endregion
}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs
index 345965b912..4e6cad1dca 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs
@@ -10,7 +10,9 @@ using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components;
+using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Compose.Components;
@@ -261,6 +263,163 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddAssert("selection preserved", () => EditorBeatmap.SelectedHitObjects.Count, () => Is.EqualTo(1));
}
+ [Test]
+ public void TestQuickDeleteOnUnselectedControlPointOnlyRemovesThatControlPoint()
+ {
+ var slider = new Slider
+ {
+ StartTime = 0,
+ Position = new Vector2(100, 100),
+ Path = new SliderPath
+ {
+ ControlPoints =
+ {
+ new PathControlPoint { Type = PathType.LINEAR },
+ new PathControlPoint(new Vector2(100, 0)),
+ new PathControlPoint(new Vector2(100)),
+ new PathControlPoint(new Vector2(0, 100))
+ }
+ }
+ };
+ AddStep("add slider", () => EditorBeatmap.Add(slider));
+ AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider));
+
+ AddStep("select second node", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1));
+ InputManager.Click(MouseButton.Left);
+ });
+ AddStep("also select third node", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(2));
+ InputManager.Click(MouseButton.Left);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ });
+ AddStep("quick-delete fourth node", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(3));
+ InputManager.Click(MouseButton.Middle);
+ });
+ AddUntilStep("slider not deleted", () => EditorBeatmap.HitObjects.OfType().Count(), () => Is.EqualTo(1));
+ AddUntilStep("slider path has 3 nodes", () => EditorBeatmap.HitObjects.OfType().Single().Path.ControlPoints.Count, () => Is.EqualTo(3));
+ }
+
+ [Test]
+ public void TestQuickDeleteOnSelectedControlPointRemovesEntireSelection()
+ {
+ var slider = new Slider
+ {
+ StartTime = 0,
+ Position = new Vector2(100, 100),
+ Path = new SliderPath
+ {
+ ControlPoints =
+ {
+ new PathControlPoint { Type = PathType.LINEAR },
+ new PathControlPoint(new Vector2(100, 0)),
+ new PathControlPoint(new Vector2(100)),
+ new PathControlPoint(new Vector2(0, 100))
+ }
+ }
+ };
+ AddStep("add slider", () => EditorBeatmap.Add(slider));
+ AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider));
+
+ AddStep("select second node", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1));
+ InputManager.Click(MouseButton.Left);
+ });
+ AddStep("also select third node", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(2));
+ InputManager.Click(MouseButton.Left);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ });
+ AddStep("quick-delete second node", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1));
+ InputManager.Click(MouseButton.Middle);
+ });
+ AddUntilStep("slider not deleted", () => EditorBeatmap.HitObjects.OfType().Count(), () => Is.EqualTo(1));
+ AddUntilStep("slider path has 2 nodes", () => EditorBeatmap.HitObjects.OfType().Single().Path.ControlPoints.Count, () => Is.EqualTo(2));
+ }
+
+ [Test]
+ public void TestSliderDragMarkerDoesNotBlockControlPointContextMenu()
+ {
+ var slider = new Slider
+ {
+ StartTime = 0,
+ Position = new Vector2(100, 100),
+ Path = new SliderPath
+ {
+ ControlPoints =
+ {
+ new PathControlPoint { Type = PathType.LINEAR },
+ new PathControlPoint(new Vector2(50, 100)),
+ new PathControlPoint(new Vector2(145, 100)),
+ },
+ ExpectedDistance = { Value = 162.62 }
+ },
+ };
+ AddStep("add slider", () => EditorBeatmap.Add(slider));
+ AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider));
+
+ AddStep("select last node", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType>().Last());
+ InputManager.Click(MouseButton.Left);
+ });
+ AddStep("right click node", () => InputManager.Click(MouseButton.Right));
+ AddUntilStep("context menu open", () => this.ChildrenOfType().Single().ChildrenOfType