From 16e69b08a161506d191ddf89e782928da79146d0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 30 Sep 2024 19:52:51 +0900 Subject: [PATCH 01/40] Avoid unnecessarily handling two skin changed events when making mutable skin --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 6f7781ee9c..eca8b7f1d2 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -401,6 +401,10 @@ namespace osu.Game.Overlays.SkinEditor private void skinChanged() { + if (skins.EnsureMutableSkin()) + // Another skin changed event will arrive which will complete the process. + return; + headerText.Clear(); headerText.AddParagraph(SkinEditorStrings.SkinEditor, cp => cp.Font = OsuFont.Default.With(size: 16)); From 1f2f4a533f8159b986f90538388845820a2c50b9 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 30 Sep 2024 19:53:06 +0900 Subject: [PATCH 02/40] Fix initial skin state being stored wrong to undo history --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index eca8b7f1d2..ec9931c673 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -422,17 +422,24 @@ namespace osu.Game.Overlays.SkinEditor }); changeHandler?.Dispose(); + changeHandler = null; - skins.EnsureMutableSkin(); + // Schedule is required to ensure that all layout in `LoadComplete` methods has been completed + // before storing an undo state. + // + // See https://github.com/ppy/osu/blob/8e6a4559e3ae8c9892866cf9cf8d4e8d1b72afd0/osu.Game/Skinning/SkinReloadableDrawable.cs#L76. + Schedule(() => + { + var targetContainer = getTarget(selectedTarget.Value); - var targetContainer = getTarget(selectedTarget.Value); + if (targetContainer != null) + changeHandler = new SkinEditorChangeHandler(targetContainer); - if (targetContainer != null) - changeHandler = new SkinEditorChangeHandler(targetContainer); - hasBegunMutating = true; + hasBegunMutating = true; - // Reload sidebar components. - selectedTarget.TriggerChange(); + // Reload sidebar components. + selectedTarget.TriggerChange(); + }); } /// From f84f6b78d9fdd4a1fda1a36c97cb4915981a3a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 16 Oct 2024 13:48:29 +0200 Subject: [PATCH 03/40] Add failing test coverage of skin editor still not undoing correctly to initial state --- .../TestSceneSkinEditorNavigation.cs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs index 5267a57a05..8323aaeaf4 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs @@ -5,6 +5,7 @@ using System; using System.Linq; +using Newtonsoft.Json; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -23,6 +24,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning; using osu.Game.Tests.Beatmaps.IO; @@ -101,6 +103,77 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("current skin is mutable", () => !Game.Dependencies.Get().CurrentSkin.Value.SkinInfo.Value.Protected); } + [Test] + public void TestMutateProtectedSkinFromMainMenu_UndoToInitialStateIsCorrect() + { + AddStep("set default skin", () => Game.Dependencies.Get().CurrentSkinInfo.SetDefault()); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + + openSkinEditor(); + AddUntilStep("current skin is mutable", () => !Game.Dependencies.Get().CurrentSkin.Value.SkinInfo.Value.Protected); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return Game.ScreenStack.CurrentScreen is Player; + }); + + string state = string.Empty; + + AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType().Any(counter => counter.IsLoaded)); + AddStep("dump state of accuracy meter", () => state = JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo())); + AddStep("add any component", () => Game.ChildrenOfType().First().TriggerClick()); + AddStep("undo", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Z); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddUntilStep("only one accuracy meter left", + () => Game.ChildrenOfType().Single().ChildrenOfType().Count(), + () => Is.EqualTo(1)); + AddAssert("accuracy meter state unchanged", + () => JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo()), + () => Is.EqualTo(state)); + } + + [Test] + public void TestMutateProtectedSkinFromPlayer_UndoToInitialStateIsCorrect() + { + AddStep("set default skin", () => Game.Dependencies.Get().CurrentSkinInfo.SetDefault()); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + advanceToSongSelect(); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("enable NF", () => Game.SelectedMods.Value = new[] { new OsuModNoFail() }); + AddStep("enter gameplay", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => + { + DismissAnyNotifications(); + return Game.ScreenStack.CurrentScreen is Player; + }); + openSkinEditor(); + + string state = string.Empty; + + AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType().Any(counter => counter.IsLoaded)); + AddStep("dump state of accuracy meter", () => state = JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo())); + AddStep("add any component", () => Game.ChildrenOfType().First().TriggerClick()); + AddStep("undo", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Z); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddUntilStep("only one accuracy meter left", + () => Game.ChildrenOfType().Single().ChildrenOfType().Count(), + () => Is.EqualTo(1)); + AddAssert("accuracy meter state unchanged", + () => JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo()), + () => Is.EqualTo(state)); + } + [Test] public void TestComponentsDeselectedOnSkinEditorHide() { From 66ca7448436e7d66072343a1c4af950da3e0d385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 16 Oct 2024 14:23:16 +0200 Subject: [PATCH 04/40] Fix `SkinEditorChangeHandler` not actually storing initial state --- osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs index 673ba873c4..b805e50df6 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs @@ -34,7 +34,7 @@ namespace osu.Game.Overlays.SkinEditor return; components = new BindableList { BindTarget = firstTarget.Components }; - components.BindCollectionChanged((_, _) => SaveState()); + components.BindCollectionChanged((_, _) => SaveState(), true); } protected override void WriteCurrentStateToStream(MemoryStream stream) From 936677f56abd22328fc9450d3b529b87a672f440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 16 Oct 2024 14:47:29 +0200 Subject: [PATCH 05/40] Fix `SkinEditor` potentially initialising change handler while components are not loaded yet --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index ec9931c673..130684e289 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -353,9 +353,10 @@ namespace osu.Game.Overlays.SkinEditor return; } - changeHandler = new SkinEditorChangeHandler(skinComponentsContainer); - changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); - changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); + if (skinComponentsContainer.IsLoaded) + bindChangeHandler(skinComponentsContainer); + else + skinComponentsContainer.OnLoadComplete += d => Schedule(() => bindChangeHandler((SkinnableContainer)d)); content.Child = new SkinBlueprintContainer(skinComponentsContainer); @@ -397,6 +398,13 @@ namespace osu.Game.Overlays.SkinEditor SelectedComponents.Clear(); placeComponent(component); } + + void bindChangeHandler(SkinnableContainer skinnableContainer) + { + changeHandler = new SkinEditorChangeHandler(skinnableContainer); + changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); + changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); + } } private void skinChanged() From 99518f4a564ed2e14895c5744a25f3af4138db64 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 4 Nov 2024 04:28:16 -0500 Subject: [PATCH 06/40] Specify type of text input in most `TextBox` usages --- osu.Game/Graphics/UserInterface/OsuNumberBox.cs | 7 +++---- osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs | 10 +++------- osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs | 6 ++++++ osu.Game/Overlays/Login/LoginForm.cs | 2 ++ osu.Game/Overlays/Settings/SettingsNumberBox.cs | 6 +++++- osu.Game/Screens/Edit/Setup/MetadataSection.cs | 6 +++++- 6 files changed, 24 insertions(+), 13 deletions(-) diff --git a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs index db4b7b2ab3..86753f6aa9 100644 --- a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs @@ -1,17 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Input; + namespace osu.Game.Graphics.UserInterface { public partial class OsuNumberBox : OsuTextBox { - protected override bool AllowIme => false; - public OsuNumberBox() { + InputProperties = new TextInputProperties(TextInputType.Number, false); SelectAllOnFocus = true; } - - protected override bool CanAddCharacter(char character) => char.IsAsciiDigit(character); } } diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index 0be7b4dc48..143962542d 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -18,7 +18,7 @@ using osu.Game.Localisation; namespace osu.Game.Graphics.UserInterface { - public partial class OsuPasswordTextBox : OsuTextBox, ISuppressKeyEventLogging + public partial class OsuPasswordTextBox : OsuTextBox { protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer { @@ -28,12 +28,6 @@ namespace osu.Game.Graphics.UserInterface protected override bool AllowUniqueCharacterSamples => false; - protected override bool AllowClipboardExport => false; - - protected override bool AllowWordNavigation => false; - - protected override bool AllowIme => false; - private readonly CapsWarning warning; [Resolved] @@ -41,6 +35,8 @@ namespace osu.Game.Graphics.UserInterface public OsuPasswordTextBox() { + InputProperties = new TextInputProperties(TextInputType.Password, false); + Add(warning = new CapsWarning { Size = new Vector2(20), diff --git a/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs index c3256e0038..61d3b3fc31 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Globalization; +using osu.Framework.Input; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -19,6 +20,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 { public bool AllowDecimals { get; init; } + public InnerNumberBox() + { + InputProperties = new TextInputProperties(TextInputType.Number, false); + } + protected override bool CanAddCharacter(char character) => char.IsAsciiDigit(character) || (AllowDecimals && CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator.Contains(character)); } diff --git a/osu.Game/Overlays/Login/LoginForm.cs b/osu.Game/Overlays/Login/LoginForm.cs index 13e528ff8f..0ff30da2a1 100644 --- a/osu.Game/Overlays/Login/LoginForm.cs +++ b/osu.Game/Overlays/Login/LoginForm.cs @@ -7,6 +7,7 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Configuration; using osu.Game.Graphics; @@ -63,6 +64,7 @@ namespace osu.Game.Overlays.Login }, username = new OsuTextBox { + InputProperties = new TextInputProperties(TextInputType.Username, false), PlaceholderText = UsersStrings.LoginUsername.ToLower(), RelativeSizeAxes = Axes.X, Text = api.ProvidedUsername, diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index fbcdb4a968..2548f3c87b 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -5,6 +5,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; namespace osu.Game.Overlays.Settings { @@ -66,7 +67,10 @@ namespace osu.Game.Overlays.Settings private partial class OutlinedNumberBox : OutlinedTextBox { - protected override bool AllowIme => false; + public OutlinedNumberBox() + { + InputProperties = new TextInputProperties(TextInputType.Number, false); + } protected override bool CanAddCharacter(char character) => char.IsAsciiDigit(character); diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 20c0a74d84..3acaefe91e 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -4,6 +4,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; @@ -119,7 +120,10 @@ namespace osu.Game.Screens.Edit.Setup private partial class RomanisedTextBox : InnerTextBox { - protected override bool AllowIme => false; + public RomanisedTextBox() + { + InputProperties = new TextInputProperties(TextInputType.Text, false); + } protected override bool CanAddCharacter(char character) => MetadataUtils.IsRomanised(character); From b3056d6114b9a3d439ae437537568fc9124c4a58 Mon Sep 17 00:00:00 2001 From: Plextora <71889427+Plextora@users.noreply.github.com> Date: Sun, 22 Dec 2024 16:58:00 -0500 Subject: [PATCH 07/40] Change score background to pink if user is friended --- osu.Game/Online/Leaderboards/LeaderboardScore.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 5651f01645..9aa0e0fbe2 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -101,6 +101,7 @@ namespace osu.Game.Online.Leaderboards private void load(IAPIProvider api, OsuColour colour) { var user = Score.User; + bool isUserFriend = api.Friends.Any(friend => friend.TargetID == user.OnlineID); statisticsLabels = GetStatistics(Score).Select(s => new ScoreComponentLabel(s)).ToList(); @@ -129,7 +130,7 @@ namespace osu.Game.Online.Leaderboards background = new Box { RelativeSizeAxes = Axes.Both, - Colour = user.OnlineID == api.LocalUser.Value.Id && isOnlineScope ? colour.Green : Color4.Black, + Colour = isUserFriend ? colour.Pink : (user.OnlineID == api.LocalUser.Value.Id && isOnlineScope ? colour.Green : Color4.Black), Alpha = background_alpha, }, }, From 47afab8a32fb312601f8d5b18fb6a9cae6de6e97 Mon Sep 17 00:00:00 2001 From: Plextora <71889427+Plextora@users.noreply.github.com> Date: Mon, 23 Dec 2024 12:47:50 -0500 Subject: [PATCH 08/40] Use yellow instead of pink --- osu.Game/Online/Leaderboards/LeaderboardScore.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 9aa0e0fbe2..32b25a866d 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -130,7 +130,7 @@ namespace osu.Game.Online.Leaderboards background = new Box { RelativeSizeAxes = Axes.Both, - Colour = isUserFriend ? colour.Pink : (user.OnlineID == api.LocalUser.Value.Id && isOnlineScope ? colour.Green : Color4.Black), + Colour = isUserFriend ? colour.Yellow : (user.OnlineID == api.LocalUser.Value.Id && isOnlineScope ? colour.Green : Color4.Black), Alpha = background_alpha, }, }, From 51b62a6d8e6877131542d2869f91158c000dcb50 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 7 Jan 2025 19:12:31 +0900 Subject: [PATCH 09/40] Display notification on friend presence changes --- .../TestSceneFriendPresenceNotifier.cs | 129 +++++++++++++++ osu.Game/Online/API/APIAccess.cs | 9 ++ osu.Game/Online/API/DummyAPIAccess.cs | 3 + osu.Game/Online/API/IAPIProvider.cs | 7 + osu.Game/Online/FriendPresenceNotifier.cs | 148 ++++++++++++++++++ osu.Game/OsuGame.cs | 1 + .../Visual/Metadata/TestMetadataClient.cs | 3 +- 7 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs create mode 100644 osu.Game/Online/FriendPresenceNotifier.cs diff --git a/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs new file mode 100644 index 0000000000..851c1141db --- /dev/null +++ b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs @@ -0,0 +1,129 @@ +// 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; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Online.Metadata; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Tests.Visual.Metadata; +using osu.Game.Users; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Components +{ + public partial class TestSceneFriendPresenceNotifier : OsuManualInputManagerTestScene + { + private ChannelManager channelManager = null!; + private NotificationOverlay notificationOverlay = null!; + private ChatOverlay chatOverlay = null!; + private TestMetadataClient metadataClient = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(ChannelManager), channelManager = new ChannelManager(API)), + (typeof(INotificationOverlay), notificationOverlay = new NotificationOverlay()), + (typeof(ChatOverlay), chatOverlay = new ChatOverlay()), + (typeof(MetadataClient), metadataClient = new TestMetadataClient()), + ], + Children = new Drawable[] + { + channelManager, + notificationOverlay, + chatOverlay, + metadataClient, + new FriendPresenceNotifier() + } + }; + + for (int i = 1; i <= 100; i++) + ((DummyAPIAccess)API).Friends.Add(new APIRelation { TargetID = i, TargetUser = new APIUser { Username = $"Friend {i}" } }); + }); + + [Test] + public void TestNotifications() + { + AddStep("bring friend 1 online", () => metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + AddStep("bring friend 1 offline", () => metadataClient.UserPresenceUpdated(1, null)); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); + } + + [Test] + public void TestSingleUserNotificationOpensChat() + { + AddStep("bring friend 1 online", () => metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("click notification", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("chat overlay opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddUntilStep("user channel selected", () => channelManager.CurrentChannel.Value.Name, () => Is.EqualTo(((DummyAPIAccess)API).Friends[0].TargetUser!.Username)); + } + + [Test] + public void TestMultipleUserNotificationDoesNotOpenChat() + { + AddStep("bring friends 1 & 2 online", () => + { + metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }); + metadataClient.UserPresenceUpdated(2, new UserPresence { Status = UserStatus.Online }); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("click notification", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("chat overlay not opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Hidden)); + } + + [Test] + public void TestNonFriendsDoNotNotify() + { + AddStep("bring non-friend 1000 online", () => metadataClient.UserPresenceUpdated(1000, new UserPresence { Status = UserStatus.Online })); + AddWaitStep("wait for possible notification", 10); + AddAssert("no notification", () => notificationOverlay.AllNotifications.Count(), () => Is.Zero); + } + + [Test] + public void TestPostManyDebounced() + { + AddStep("bring friends 1-10 online", () => + { + for (int i = 1; i <= 10; i++) + metadataClient.UserPresenceUpdated(i, new UserPresence { Status = UserStatus.Online }); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("bring friends 1-10 offline", () => + { + for (int i = 1; i <= 10; i++) + metadataClient.UserPresenceUpdated(i, null); + }); + + AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); + } + } +} diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index ec48fa2436..39c09f2a5d 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -75,6 +75,7 @@ namespace osu.Game.Online.API protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); + private readonly Dictionary friendsMapping = new Dictionary(); private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); private readonly Logger log; @@ -403,6 +404,8 @@ namespace osu.Game.Online.API public IChatClient GetChatClient() => new WebSocketChatClient(this); + public APIRelation GetFriend(int userId) => friendsMapping.GetValueOrDefault(userId); + public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Debug.Assert(State.Value == APIState.Offline); @@ -594,6 +597,8 @@ namespace osu.Game.Online.API Schedule(() => { setLocalUser(createGuestUser()); + + friendsMapping.Clear(); friends.Clear(); }); @@ -610,7 +615,11 @@ namespace osu.Game.Online.API friendsReq.Failure += _ => state.Value = APIState.Failing; friendsReq.Success += res => { + friendsMapping.Clear(); friends.Clear(); + + foreach (var u in res) + friendsMapping[u.TargetID] = u; friends.AddRange(res); }; diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 5d63c04925..ca4edb3d8f 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Bindables; @@ -194,6 +195,8 @@ namespace osu.Game.Online.API public IChatClient GetChatClient() => new TestChatClientConnector(this); + public APIRelation? GetFriend(int userId) => Friends.FirstOrDefault(r => r.TargetID == userId); + public RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password) { Thread.Sleep(200); diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 1c4b2da742..4655b26f84 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -152,6 +152,13 @@ namespace osu.Game.Online.API /// IChatClient GetChatClient(); + /// + /// Retrieves a friend from a given user ID. + /// + /// The friend's user ID. + /// The object representing the friend, if any. + APIRelation? GetFriend(int userId); + /// /// Create a new user account. This is a blocking operation. /// diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs new file mode 100644 index 0000000000..8fcf1a9f69 --- /dev/null +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -0,0 +1,148 @@ +// 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.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Online.Metadata; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Users; + +namespace osu.Game.Online +{ + public partial class FriendPresenceNotifier : Component + { + [Resolved] + private INotificationOverlay notifications { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + + [Resolved] + private ChannelManager channelManager { get; set; } = null!; + + [Resolved] + private ChatOverlay chatOverlay { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private readonly IBindableDictionary userStates = new BindableDictionary(); + private readonly HashSet onlineAlertQueue = new HashSet(); + private readonly HashSet offlineAlertQueue = new HashSet(); + + private double? lastOnlineAlertTime; + private double? lastOfflineAlertTime; + + protected override void LoadComplete() + { + base.LoadComplete(); + + userStates.BindTo(metadataClient.UserStates); + userStates.BindCollectionChanged((_, args) => + { + switch (args.Action) + { + case NotifyDictionaryChangedAction.Add: + foreach ((int userId, var _) in args.NewItems!) + { + if (api.GetFriend(userId)?.TargetUser is APIUser user) + { + if (!offlineAlertQueue.Remove(user)) + { + onlineAlertQueue.Add(user); + lastOnlineAlertTime ??= Time.Current; + } + } + } + + break; + + case NotifyDictionaryChangedAction.Remove: + foreach ((int userId, var _) in args.OldItems!) + { + if (api.GetFriend(userId)?.TargetUser is APIUser user) + { + if (!onlineAlertQueue.Remove(user)) + { + offlineAlertQueue.Add(user); + lastOfflineAlertTime ??= Time.Current; + } + } + } + + break; + } + }); + } + + protected override void Update() + { + base.Update(); + + alertOnlineUsers(); + alertOfflineUsers(); + } + + private void alertOnlineUsers() + { + if (onlineAlertQueue.Count == 0) + return; + + if (lastOnlineAlertTime == null || Time.Current - lastOnlineAlertTime < 1000) + return; + + APIUser? singleUser = onlineAlertQueue.Count == 1 ? onlineAlertQueue.Single() : null; + + notifications.Post(new SimpleNotification + { + Icon = FontAwesome.Solid.UserPlus, + Text = $"Online: {string.Join(@", ", onlineAlertQueue.Select(u => u.Username))}", + IconColour = colours.Green, + Activated = () => + { + if (singleUser != null) + { + channelManager.OpenPrivateChannel(singleUser); + chatOverlay.Show(); + } + + return true; + } + }); + + onlineAlertQueue.Clear(); + lastOnlineAlertTime = null; + } + + private void alertOfflineUsers() + { + if (offlineAlertQueue.Count == 0) + return; + + if (lastOfflineAlertTime == null || Time.Current - lastOfflineAlertTime < 1000) + return; + + notifications.Post(new SimpleNotification + { + Icon = FontAwesome.Solid.UserMinus, + Text = $"Offline: {string.Join(@", ", offlineAlertQueue.Select(u => u.Username))}", + IconColour = colours.Red + }); + + offlineAlertQueue.Clear(); + lastOfflineAlertTime = null; + } + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index c20536a1ec..329ac89a6c 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1151,6 +1151,7 @@ namespace osu.Game Add(externalLinkOpener = new ExternalLinkOpener()); Add(new MusicKeyBindingHandler()); Add(new OnlineStatusNotifier(() => ScreenStack.CurrentScreen)); + Add(new FriendPresenceNotifier()); // side overlays which cancel each other. var singleDisplaySideOverlays = new OverlayContainer[] { Settings, Notifications, FirstRunOverlay }; diff --git a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs index 4a862750bc..6dd6392b3a 100644 --- a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs +++ b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -66,7 +67,7 @@ namespace osu.Game.Tests.Visual.Metadata public override Task UserPresenceUpdated(int userId, UserPresence? presence) { - if (isWatchingUserPresence.Value) + if (isWatchingUserPresence.Value || api.Friends.Any(f => f.TargetID == userId)) { if (presence.HasValue) userStates[userId] = presence.Value; From 45e0adcd253f1dfa922723c502dab365b76f51cd Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 7 Jan 2025 19:32:30 +0900 Subject: [PATCH 10/40] Add config option --- osu.Game/Configuration/OsuConfigManager.cs | 2 ++ .../Localisation/OnlineSettingsStrings.cs | 12 +++++++++++- osu.Game/Online/FriendPresenceNotifier.cs | 19 +++++++++++++++++++ .../Online/AlertsAndPrivacySettings.cs | 6 ++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index dd3abb6f81..3c463f6f0c 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -96,6 +96,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.NotifyOnUsernameMentioned, true); SetDefault(OsuSetting.NotifyOnPrivateMessage, true); + SetDefault(OsuSetting.NotifyOnFriendPresenceChange, true); // Audio SetDefault(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01); @@ -417,6 +418,7 @@ namespace osu.Game.Configuration IntroSequence, NotifyOnUsernameMentioned, NotifyOnPrivateMessage, + NotifyOnFriendPresenceChange, UIHoldActivationDelay, HitLighting, StarFountains, diff --git a/osu.Game/Localisation/OnlineSettingsStrings.cs b/osu.Game/Localisation/OnlineSettingsStrings.cs index 8e8c81cf59..98364a3f5a 100644 --- a/osu.Game/Localisation/OnlineSettingsStrings.cs +++ b/osu.Game/Localisation/OnlineSettingsStrings.cs @@ -29,6 +29,16 @@ namespace osu.Game.Localisation /// public static LocalisableString NotifyOnPrivateMessage => new TranslatableString(getKey(@"notify_on_private_message"), @"Show a notification when you receive a private message"); + /// + /// "Show notification popups when friends change status" + /// + public static LocalisableString NotifyOnFriendPresenceChange => new TranslatableString(getKey(@"notify_on_friend_presence_change"), @"Show notification popups when friends change status"); + + /// + /// "Notifications will be shown when friends go online/offline." + /// + public static LocalisableString NotifyOnFriendPresenceChangeTooltip => new TranslatableString(getKey(@"notify_on_friend_presence_change_tooltip"), @"Notifications will be shown when friends go online/offline."); + /// /// "Integrations" /// @@ -84,6 +94,6 @@ namespace osu.Game.Localisation /// public static LocalisableString HideCountryFlags => new TranslatableString(getKey(@"hide_country_flags"), @"Hide country flags"); - private static string getKey(string key) => $"{prefix}:{key}"; + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs index 8fcf1a9f69..655a004d3e 100644 --- a/osu.Game/Online/FriendPresenceNotifier.cs +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -7,6 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -38,6 +39,10 @@ namespace osu.Game.Online [Resolved] private OsuColour colours { get; set; } = null!; + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private readonly Bindable notifyOnFriendPresenceChange = new BindableBool(); private readonly IBindableDictionary userStates = new BindableDictionary(); private readonly HashSet onlineAlertQueue = new HashSet(); private readonly HashSet offlineAlertQueue = new HashSet(); @@ -49,6 +54,8 @@ namespace osu.Game.Online { base.LoadComplete(); + config.BindWith(OsuSetting.NotifyOnFriendPresenceChange, notifyOnFriendPresenceChange); + userStates.BindTo(metadataClient.UserStates); userStates.BindCollectionChanged((_, args) => { @@ -103,6 +110,12 @@ namespace osu.Game.Online if (lastOnlineAlertTime == null || Time.Current - lastOnlineAlertTime < 1000) return; + if (!notifyOnFriendPresenceChange.Value) + { + lastOnlineAlertTime = null; + return; + } + APIUser? singleUser = onlineAlertQueue.Count == 1 ? onlineAlertQueue.Single() : null; notifications.Post(new SimpleNotification @@ -134,6 +147,12 @@ namespace osu.Game.Online if (lastOfflineAlertTime == null || Time.Current - lastOfflineAlertTime < 1000) return; + if (!notifyOnFriendPresenceChange.Value) + { + lastOfflineAlertTime = null; + return; + } + notifications.Post(new SimpleNotification { Icon = FontAwesome.Solid.UserMinus, diff --git a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs index 7bd0829add..608c6ef1b2 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs @@ -29,6 +29,12 @@ namespace osu.Game.Overlays.Settings.Sections.Online Current = config.GetBindable(OsuSetting.NotifyOnPrivateMessage) }, new SettingsCheckbox + { + LabelText = OnlineSettingsStrings.NotifyOnFriendPresenceChange, + TooltipText = OnlineSettingsStrings.NotifyOnFriendPresenceChangeTooltip, + Current = config.GetBindable(OsuSetting.NotifyOnFriendPresenceChange), + }, + new SettingsCheckbox { LabelText = OnlineSettingsStrings.HideCountryFlags, Current = config.GetBindable(OsuSetting.HideCountryFlags) From 8f4eafea4eab7a1a2e7d4b3571732477509ba0cf Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Tue, 7 Jan 2025 14:00:31 +0300 Subject: [PATCH 11/40] Fix combo properties multiple reassignments --- .../Objects/CatchHitObject.cs | 36 ++++++++++--------- osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs | 36 ++++++++++--------- .../Objects/Types/IHasComboInformation.cs | 16 +++++---- 3 files changed, 48 insertions(+), 40 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index 2018fd5ea9..3c7ead09af 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs @@ -159,27 +159,29 @@ namespace osu.Game.Rulesets.Catch.Objects { // Note that this implementation is shared with the osu! ruleset's implementation. // If a change is made here, OsuHitObject.cs should also be updated. - ComboIndex = lastObj?.ComboIndex ?? 0; - ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; - IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; + int index = lastObj?.ComboIndex ?? 0; + int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; + int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; - if (this is BananaShower) + // For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so. + if (this is not BananaShower) { - // For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so. - return; + // At decode time, the first hitobject in the beatmap and the first hitobject after a banana shower are both enforced to be a new combo, + // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here. + if (NewCombo || lastObj == null || lastObj is BananaShower) + { + inCurrentCombo = 0; + index++; + indexWithOffsets += ComboOffset + 1; + + if (lastObj != null) + lastObj.LastInCombo = true; + } } - // At decode time, the first hitobject in the beatmap and the first hitobject after a banana shower are both enforced to be a new combo, - // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here. - if (NewCombo || lastObj == null || lastObj is BananaShower) - { - IndexInCurrentCombo = 0; - ComboIndex++; - ComboIndexWithOffsets += ComboOffset + 1; - - if (lastObj != null) - lastObj.LastInCombo = true; - } + ComboIndex = index; + ComboIndexWithOffsets = indexWithOffsets; + IndexInCurrentCombo = inCurrentCombo; } protected override HitWindows CreateHitWindows() => HitWindows.Empty; diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 8c1bd6302e..937e0bda23 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -184,27 +184,29 @@ namespace osu.Game.Rulesets.Osu.Objects { // Note that this implementation is shared with the osu!catch ruleset's implementation. // If a change is made here, CatchHitObject.cs should also be updated. - ComboIndex = lastObj?.ComboIndex ?? 0; - ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; - IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; + int index = lastObj?.ComboIndex ?? 0; + int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; + int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; - if (this is Spinner) + // For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so. + if (this is not Spinner) { - // For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so. - return; + // At decode time, the first hitobject in the beatmap and the first hitobject after a spinner are both enforced to be a new combo, + // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here. + if (NewCombo || lastObj == null || lastObj is Spinner) + { + inCurrentCombo = 0; + index++; + indexWithOffsets += ComboOffset + 1; + + if (lastObj != null) + lastObj.LastInCombo = true; + } } - // At decode time, the first hitobject in the beatmap and the first hitobject after a spinner are both enforced to be a new combo, - // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here. - if (NewCombo || lastObj == null || lastObj is Spinner) - { - IndexInCurrentCombo = 0; - ComboIndex++; - ComboIndexWithOffsets += ComboOffset + 1; - - if (lastObj != null) - lastObj.LastInCombo = true; - } + ComboIndex = index; + ComboIndexWithOffsets = indexWithOffsets; + IndexInCurrentCombo = inCurrentCombo; } protected override HitWindows CreateHitWindows() => new OsuHitWindows(); diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index 3aa68197ec..98519de981 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -84,19 +84,23 @@ namespace osu.Game.Rulesets.Objects.Types /// The previous hitobject, or null if this is the first object in the beatmap. void UpdateComboInformation(IHasComboInformation? lastObj) { - ComboIndex = lastObj?.ComboIndex ?? 0; - ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; - IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; + int index = lastObj?.ComboIndex ?? 0; + int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; + int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; if (NewCombo || lastObj == null) { - IndexInCurrentCombo = 0; - ComboIndex++; - ComboIndexWithOffsets += ComboOffset + 1; + inCurrentCombo = 0; + index++; + indexWithOffsets += ComboOffset + 1; if (lastObj != null) lastObj.LastInCombo = true; } + + ComboIndex = index; + ComboIndexWithOffsets = indexWithOffsets; + IndexInCurrentCombo = inCurrentCombo; } } } From f4d83fe6851272375f2382ffc2dd0c0d89721f93 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 9 Jan 2025 13:23:16 +0900 Subject: [PATCH 12/40] Keep friend states when stopping watching global activity --- .../Online/Metadata/OnlineMetadataClient.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index a3041c6753..ef748f0b49 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; @@ -31,10 +32,11 @@ namespace osu.Game.Online.Metadata private readonly string endpoint; + [Resolved] + private IAPIProvider api { get; set; } = null!; + private IHubClientConnector? connector; - private Bindable lastQueueId = null!; - private IBindable localUser = null!; private IBindable userActivity = null!; private IBindable? userStatus; @@ -47,7 +49,7 @@ namespace osu.Game.Online.Metadata } [BackgroundDependencyLoader] - private void load(IAPIProvider api, OsuConfigManager config) + private void load(OsuConfigManager config) { // Importantly, we are intentionally not using MessagePack here to correctly support derived class serialization. // More information on the limitations / reasoning can be found in osu-server-spectator's initialisation code. @@ -226,7 +228,15 @@ namespace osu.Game.Online.Metadata throw new OperationCanceledException(); // must be scheduled before any remote calls to avoid mis-ordering. - Schedule(() => userStates.Clear()); + Schedule(() => + { + foreach (int userId in userStates.Keys.ToArray()) + { + if (api.GetFriend(userId) == null) + userStates.Remove(userId); + } + }); + Debug.Assert(connection != null); await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)).ConfigureAwait(false); Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network); From 7268b2e077ab95347a12d5374cbdf505ff8538d1 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 9 Jan 2025 17:31:01 +0900 Subject: [PATCH 13/40] Add separate path for friend presence notifications It proved to be too difficult to deal with the flow that clears user states on stopping the watching of global presence updates. It's not helped in the least that friends are updated via the API, so there's a third flow to consider (and the timings therein - both server-spectator and friends are updated concurrently). Simplest is to separate the friends flow, though this does mean some logic and state duplication. --- .../TestSceneFriendPresenceNotifier.cs | 14 +- osu.Game/Online/API/APIAccess.cs | 19 ++- osu.Game/Online/API/DummyAPIAccess.cs | 3 - osu.Game/Online/API/IAPIProvider.cs | 7 - osu.Game/Online/FriendPresenceNotifier.cs | 121 ++++++++++++------ osu.Game/Online/Metadata/IMetadataClient.cs | 5 + osu.Game/Online/Metadata/MetadataClient.cs | 8 ++ .../Online/Metadata/OnlineMetadataClient.cs | 34 +++-- .../Visual/Metadata/TestMetadataClient.cs | 16 ++- 9 files changed, 148 insertions(+), 79 deletions(-) diff --git a/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs index 851c1141db..2fe2326508 100644 --- a/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs +++ b/osu.Game.Tests/Visual/Components/TestSceneFriendPresenceNotifier.cs @@ -56,16 +56,16 @@ namespace osu.Game.Tests.Visual.Components [Test] public void TestNotifications() { - AddStep("bring friend 1 online", () => metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); - AddStep("bring friend 1 offline", () => metadataClient.UserPresenceUpdated(1, null)); + AddStep("bring friend 1 offline", () => metadataClient.FriendPresenceUpdated(1, null)); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); } [Test] public void TestSingleUserNotificationOpensChat() { - AddStep("bring friend 1 online", () => metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); + AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online })); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); AddStep("click notification", () => @@ -83,8 +83,8 @@ namespace osu.Game.Tests.Visual.Components { AddStep("bring friends 1 & 2 online", () => { - metadataClient.UserPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }); - metadataClient.UserPresenceUpdated(2, new UserPresence { Status = UserStatus.Online }); + metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }); + metadataClient.FriendPresenceUpdated(2, new UserPresence { Status = UserStatus.Online }); }); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); @@ -112,7 +112,7 @@ namespace osu.Game.Tests.Visual.Components AddStep("bring friends 1-10 online", () => { for (int i = 1; i <= 10; i++) - metadataClient.UserPresenceUpdated(i, new UserPresence { Status = UserStatus.Online }); + metadataClient.FriendPresenceUpdated(i, new UserPresence { Status = UserStatus.Online }); }); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); @@ -120,7 +120,7 @@ namespace osu.Game.Tests.Visual.Components AddStep("bring friends 1-10 offline", () => { for (int i = 1; i <= 10; i++) - metadataClient.UserPresenceUpdated(i, null); + metadataClient.FriendPresenceUpdated(i, null); }); AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2)); diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 39c09f2a5d..46476ab7f0 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; @@ -18,6 +19,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Localisation; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -75,7 +77,6 @@ namespace osu.Game.Online.API protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); - private readonly Dictionary friendsMapping = new Dictionary(); private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); private readonly Logger log; @@ -404,8 +405,6 @@ namespace osu.Game.Online.API public IChatClient GetChatClient() => new WebSocketChatClient(this); - public APIRelation GetFriend(int userId) => friendsMapping.GetValueOrDefault(userId); - public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Debug.Assert(State.Value == APIState.Offline); @@ -597,8 +596,6 @@ namespace osu.Game.Online.API Schedule(() => { setLocalUser(createGuestUser()); - - friendsMapping.Clear(); friends.Clear(); }); @@ -615,12 +612,14 @@ namespace osu.Game.Online.API friendsReq.Failure += _ => state.Value = APIState.Failing; friendsReq.Success += res => { - friendsMapping.Clear(); - friends.Clear(); + // Add new friends into local list. + HashSet friendsSet = friends.Select(f => f.TargetID).ToHashSet(); + friends.AddRange(res.Where(f => !friendsSet.Contains(f.TargetID))); - foreach (var u in res) - friendsMapping[u.TargetID] = u; - friends.AddRange(res); + // Remove non-friends from local lists. + friendsSet.Clear(); + friendsSet.AddRange(res.Select(f => f.TargetID)); + friends.RemoveAll(f => !friendsSet.Contains(f.TargetID)); }; Queue(friendsReq); diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index ca4edb3d8f..5d63c04925 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Bindables; @@ -195,8 +194,6 @@ namespace osu.Game.Online.API public IChatClient GetChatClient() => new TestChatClientConnector(this); - public APIRelation? GetFriend(int userId) => Friends.FirstOrDefault(r => r.TargetID == userId); - public RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password) { Thread.Sleep(200); diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 4655b26f84..1c4b2da742 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -152,13 +152,6 @@ namespace osu.Game.Online.API /// IChatClient GetChatClient(); - /// - /// Retrieves a friend from a given user ID. - /// - /// The friend's user ID. - /// The object representing the friend, if any. - APIRelation? GetFriend(int userId); - /// /// Create a new user account. This is a blocking operation. /// diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs index 655a004d3e..330e0a908f 100644 --- a/osu.Game/Online/FriendPresenceNotifier.cs +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -43,7 +44,10 @@ namespace osu.Game.Online private OsuConfigManager config { get; set; } = null!; private readonly Bindable notifyOnFriendPresenceChange = new BindableBool(); - private readonly IBindableDictionary userStates = new BindableDictionary(); + + private readonly IBindableList friends = new BindableList(); + private readonly IBindableDictionary friendStates = new BindableDictionary(); + private readonly HashSet onlineAlertQueue = new HashSet(); private readonly HashSet offlineAlertQueue = new HashSet(); @@ -56,42 +60,11 @@ namespace osu.Game.Online config.BindWith(OsuSetting.NotifyOnFriendPresenceChange, notifyOnFriendPresenceChange); - userStates.BindTo(metadataClient.UserStates); - userStates.BindCollectionChanged((_, args) => - { - switch (args.Action) - { - case NotifyDictionaryChangedAction.Add: - foreach ((int userId, var _) in args.NewItems!) - { - if (api.GetFriend(userId)?.TargetUser is APIUser user) - { - if (!offlineAlertQueue.Remove(user)) - { - onlineAlertQueue.Add(user); - lastOnlineAlertTime ??= Time.Current; - } - } - } + friends.BindTo(api.Friends); + friends.BindCollectionChanged(onFriendsChanged, true); - break; - - case NotifyDictionaryChangedAction.Remove: - foreach ((int userId, var _) in args.OldItems!) - { - if (api.GetFriend(userId)?.TargetUser is APIUser user) - { - if (!onlineAlertQueue.Remove(user)) - { - offlineAlertQueue.Add(user); - lastOfflineAlertTime ??= Time.Current; - } - } - } - - break; - } - }); + friendStates.BindTo(metadataClient.FriendStates); + friendStates.BindCollectionChanged(onFriendStatesChanged, true); } protected override void Update() @@ -102,6 +75,82 @@ namespace osu.Game.Online alertOfflineUsers(); } + private void onFriendsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (APIRelation friend in e.NewItems!.Cast()) + { + if (friend.TargetUser is not APIUser user) + continue; + + if (friendStates.TryGetValue(friend.TargetID, out _)) + markUserOnline(user); + } + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (APIRelation friend in e.OldItems!.Cast()) + { + if (friend.TargetUser is not APIUser user) + continue; + + onlineAlertQueue.Remove(user); + offlineAlertQueue.Remove(user); + } + + break; + } + } + + private void onFriendStatesChanged(object? sender, NotifyDictionaryChangedEventArgs e) + { + switch (e.Action) + { + case NotifyDictionaryChangedAction.Add: + foreach ((int friendId, _) in e.NewItems!) + { + APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId); + + if (friend?.TargetUser is APIUser user) + markUserOnline(user); + } + + break; + + case NotifyDictionaryChangedAction.Remove: + foreach ((int friendId, _) in e.OldItems!) + { + APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId); + + if (friend?.TargetUser is APIUser user) + markUserOffline(user); + } + + break; + } + } + + private void markUserOnline(APIUser user) + { + if (!offlineAlertQueue.Remove(user)) + { + onlineAlertQueue.Add(user); + lastOnlineAlertTime ??= Time.Current; + } + } + + private void markUserOffline(APIUser user) + { + if (!onlineAlertQueue.Remove(user)) + { + offlineAlertQueue.Add(user); + lastOfflineAlertTime ??= Time.Current; + } + } + private void alertOnlineUsers() { if (onlineAlertQueue.Count == 0) diff --git a/osu.Game/Online/Metadata/IMetadataClient.cs b/osu.Game/Online/Metadata/IMetadataClient.cs index 97c1bbde5f..a4251fae80 100644 --- a/osu.Game/Online/Metadata/IMetadataClient.cs +++ b/osu.Game/Online/Metadata/IMetadataClient.cs @@ -21,6 +21,11 @@ namespace osu.Game.Online.Metadata /// Task UserPresenceUpdated(int userId, UserPresence? status); + /// + /// Delivers and update of the of a friend with the supplied . + /// + Task FriendPresenceUpdated(int userId, UserPresence? presence); + /// /// Delivers an update of the current "daily challenge" status. /// Null value means there is no "daily challenge" currently active. diff --git a/osu.Game/Online/Metadata/MetadataClient.cs b/osu.Game/Online/Metadata/MetadataClient.cs index 8a5fe1733e..6578f70f74 100644 --- a/osu.Game/Online/Metadata/MetadataClient.cs +++ b/osu.Game/Online/Metadata/MetadataClient.cs @@ -42,6 +42,11 @@ namespace osu.Game.Online.Metadata /// public abstract IBindableDictionary UserStates { get; } + /// + /// Dictionary keyed by user ID containing all of the information about currently online friends received from the server. + /// + public abstract IBindableDictionary FriendStates { get; } + /// public abstract Task UpdateActivity(UserActivity? activity); @@ -57,6 +62,9 @@ namespace osu.Game.Online.Metadata /// public abstract Task UserPresenceUpdated(int userId, UserPresence? presence); + /// + public abstract Task FriendPresenceUpdated(int userId, UserPresence? presence); + #endregion #region Daily Challenge diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index ef748f0b49..a8a14b1c78 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -3,7 +3,6 @@ using System; using System.Diagnostics; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; @@ -27,14 +26,14 @@ namespace osu.Game.Online.Metadata public override IBindableDictionary UserStates => userStates; private readonly BindableDictionary userStates = new BindableDictionary(); + public override IBindableDictionary FriendStates => friendStates; + private readonly BindableDictionary friendStates = new BindableDictionary(); + public override IBindable DailyChallengeInfo => dailyChallengeInfo; private readonly Bindable dailyChallengeInfo = new Bindable(); private readonly string endpoint; - [Resolved] - private IAPIProvider api { get; set; } = null!; - private IHubClientConnector? connector; private Bindable lastQueueId = null!; private IBindable localUser = null!; @@ -49,7 +48,7 @@ namespace osu.Game.Online.Metadata } [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(IAPIProvider api, OsuConfigManager config) { // Importantly, we are intentionally not using MessagePack here to correctly support derived class serialization. // More information on the limitations / reasoning can be found in osu-server-spectator's initialisation code. @@ -63,6 +62,7 @@ namespace osu.Game.Online.Metadata // https://github.com/dotnet/aspnetcore/issues/15198 connection.On(nameof(IMetadataClient.BeatmapSetsUpdated), ((IMetadataClient)this).BeatmapSetsUpdated); connection.On(nameof(IMetadataClient.UserPresenceUpdated), ((IMetadataClient)this).UserPresenceUpdated); + connection.On(nameof(IMetadataClient.FriendPresenceUpdated), ((IMetadataClient)this).FriendPresenceUpdated); connection.On(nameof(IMetadataClient.DailyChallengeUpdated), ((IMetadataClient)this).DailyChallengeUpdated); connection.On(nameof(IMetadataClient.MultiplayerRoomScoreSet), ((IMetadataClient)this).MultiplayerRoomScoreSet); connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMetadataClient)this).DisconnectRequested); @@ -108,6 +108,7 @@ namespace osu.Game.Online.Metadata { isWatchingUserPresence.Value = false; userStates.Clear(); + friendStates.Clear(); dailyChallengeInfo.Value = null; }); return; @@ -209,6 +210,19 @@ namespace osu.Game.Online.Metadata return Task.CompletedTask; } + public override Task FriendPresenceUpdated(int userId, UserPresence? presence) + { + Schedule(() => + { + if (presence?.Status != null) + friendStates[userId] = presence.Value; + else + friendStates.Remove(userId); + }); + + return Task.CompletedTask; + } + public override async Task BeginWatchingUserPresence() { if (connector?.IsConnected.Value != true) @@ -228,15 +242,7 @@ namespace osu.Game.Online.Metadata throw new OperationCanceledException(); // must be scheduled before any remote calls to avoid mis-ordering. - Schedule(() => - { - foreach (int userId in userStates.Keys.ToArray()) - { - if (api.GetFriend(userId) == null) - userStates.Remove(userId); - } - }); - + Schedule(() => userStates.Clear()); Debug.Assert(connection != null); await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)).ConfigureAwait(false); Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network); diff --git a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs index 6dd6392b3a..36f79a5adc 100644 --- a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs +++ b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -23,6 +22,9 @@ namespace osu.Game.Tests.Visual.Metadata public override IBindableDictionary UserStates => userStates; private readonly BindableDictionary userStates = new BindableDictionary(); + public override IBindableDictionary FriendStates => friendStates; + private readonly BindableDictionary friendStates = new BindableDictionary(); + public override Bindable DailyChallengeInfo => dailyChallengeInfo; private readonly Bindable dailyChallengeInfo = new Bindable(); @@ -67,7 +69,7 @@ namespace osu.Game.Tests.Visual.Metadata public override Task UserPresenceUpdated(int userId, UserPresence? presence) { - if (isWatchingUserPresence.Value || api.Friends.Any(f => f.TargetID == userId)) + if (isWatchingUserPresence.Value) { if (presence.HasValue) userStates[userId] = presence.Value; @@ -78,6 +80,16 @@ namespace osu.Game.Tests.Visual.Metadata return Task.CompletedTask; } + public override Task FriendPresenceUpdated(int userId, UserPresence? presence) + { + if (presence.HasValue) + friendStates[userId] = presence.Value; + else + friendStates.Remove(userId); + + return Task.CompletedTask; + } + public override Task GetChangesSince(int queueId) => Task.FromResult(new BeatmapUpdates(Array.Empty(), queueId)); From 48196949e080e1f0057d20e3bb637cfc9b4989fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 9 Jan 2025 15:29:40 +0100 Subject: [PATCH 14/40] Add combo colour override control to editor Closes https://github.com/ppy/osu/issues/25608. Logic mostly matching stable. All operations are done on `ComboOffset` which still makes overridden combo colours weirdly relatively dependent on each other rather than them be an "absolute" choice, but alas... As per stable, two consecutive new combos can use the same colour only if they are separated by a break: https://github.com/peppy/osu-stable-reference/blob/52f3f75ed7efd7b9eb56e1e45c95bb91504337be/osu!/GameModes/Edit/Modes/EditorModeCompose.cs#L4564-L4571 This control is only available once the user has changed the combo colours from defaults; additionally, only a single new combo object must be selected for the colour selector to show up. --- .../Edit/CatchHitObjectComposer.cs | 3 +- .../Edit/OsuHitObjectComposer.cs | 2 +- osu.Game/Rulesets/Edit/HitObjectComposer.cs | 5 +- .../Objects/Types/IHasComboInformation.cs | 3 + .../TernaryButtons/NewComboTernaryButton.cs | 278 ++++++++++++++++++ .../Components/ComposeBlueprintContainer.cs | 11 +- 6 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index e0d80e0e64..7bb5539963 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -18,7 +18,6 @@ using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; -using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -72,7 +71,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.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index e8b9d0544e..f5e7ff6004 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Osu.Edit protected override Drawable CreateHitObjectInspector() => new OsuHitObjectInspector(); - protected override IEnumerable CreateTernaryButtons() + protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons() .Append(new DrawableTernaryButton { diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 9f277b6190..15b60114af 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; @@ -370,7 +371,7 @@ namespace osu.Game.Rulesets.Edit /// /// Create all ternary states required to be displayed to the user. /// - protected virtual IEnumerable CreateTernaryButtons() => BlueprintContainer.MainTernaryStates; + protected virtual IEnumerable CreateTernaryButtons() => BlueprintContainer.MainTernaryStates; /// /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. @@ -429,7 +430,7 @@ namespace osu.Game.Rulesets.Edit } else { - if (togglesCollection.ElementAtOrDefault(rightIndex) is DrawableTernaryButton button) + if (togglesCollection.ChildrenOfType().ElementAtOrDefault(rightIndex) is DrawableTernaryButton button) { button.Toggle(); return true; diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index 3aa68197ec..cc521aeab7 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -50,6 +50,9 @@ namespace osu.Game.Rulesets.Objects.Types /// new bool NewCombo { get; set; } + /// + new int ComboOffset { get; set; } + /// /// Bindable exposure of . /// diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs new file mode 100644 index 0000000000..effe35c0c3 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs @@ -0,0 +1,278 @@ +// 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.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Screens.Edit.Components.TernaryButtons +{ + public partial class NewComboTernaryButton : CompositeDrawable, IHasCurrentValue + { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + private readonly BindableList selectedHitObjects = new BindableList(); + private readonly BindableList comboColours = new BindableList(); + + private Container mainButtonContainer = null!; + private ColourPickerButton pickerButton = null!; + + [BackgroundDependencyLoader] + private void load(EditorBeatmap editorBeatmap) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChildren = new Drawable[] + { + mainButtonContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 30 }, + Child = new DrawableTernaryButton + { + Current = Current, + Description = "New combo", + CreateIcon = () => new SpriteIcon { Icon = OsuIcon.EditorNewComboA }, + }, + }, + pickerButton = new ColourPickerButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Width = 25, + ComboColours = { BindTarget = comboColours } + } + }; + + selectedHitObjects.BindTo(editorBeatmap.SelectedHitObjects); + if (editorBeatmap.BeatmapSkin != null) + comboColours.BindTo(editorBeatmap.BeatmapSkin.ComboColours); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedHitObjects.BindCollectionChanged((_, _) => updateState()); + comboColours.BindCollectionChanged((_, _) => updateState()); + Current.BindValueChanged(_ => updateState(), true); + } + + private void updateState() + { + if (Current.Value == TernaryState.True && selectedHitObjects.Count == 1 && selectedHitObjects.Single() is IHasComboInformation hasCombo && comboColours.Count > 1) + { + mainButtonContainer.Padding = new MarginPadding { Right = 30 }; + pickerButton.SelectedHitObject.Value = hasCombo; + pickerButton.Alpha = 1; + } + else + { + mainButtonContainer.Padding = new MarginPadding(); + pickerButton.Alpha = 0; + } + } + + private partial class ColourPickerButton : OsuButton, IHasPopover + { + public BindableList ComboColours { get; } = new BindableList(); + public Bindable SelectedHitObject { get; } = new Bindable(); + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private SpriteIcon icon = null!; + + [BackgroundDependencyLoader] + private void load() + { + Add(icon = new SpriteIcon + { + Icon = FontAwesome.Solid.Palette, + Size = new Vector2(16), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + ComboColours.BindCollectionChanged((_, _) => updateState()); + SelectedHitObject.BindValueChanged(val => + { + if (val.OldValue != null) + val.OldValue.ComboIndexWithOffsetsBindable.ValueChanged -= onComboIndexChanged; + + updateState(); + + if (val.NewValue != null) + val.NewValue.ComboIndexWithOffsetsBindable.ValueChanged += onComboIndexChanged; + }, true); + } + + private void onComboIndexChanged(ValueChangedEvent _) => updateState(); + + private void updateState() + { + if (SelectedHitObject.Value == null) + { + BackgroundColour = colourProvider.Background3; + icon.Colour = BackgroundColour.Darken(0.5f); + icon.Blending = BlendingParameters.Additive; + Enabled.Value = false; + } + else + { + BackgroundColour = ComboColours[comboIndexFor(SelectedHitObject.Value, ComboColours)]; + icon.Colour = OsuColour.ForegroundTextColourFor(BackgroundColour); + icon.Blending = BlendingParameters.Inherit; + Enabled.Value = true; + } + } + + public Popover GetPopover() => new ComboColourPalettePopover(ComboColours, SelectedHitObject.Value.AsNonNull(), editorBeatmap); + } + + private partial class ComboColourPalettePopover : OsuPopover + { + private readonly IReadOnlyList comboColours; + private readonly IHasComboInformation hasComboInformation; + private readonly EditorBeatmap editorBeatmap; + + public ComboColourPalettePopover(IReadOnlyList comboColours, IHasComboInformation hasComboInformation, EditorBeatmap editorBeatmap) + { + this.comboColours = comboColours; + this.hasComboInformation = hasComboInformation; + this.editorBeatmap = editorBeatmap; + + AllowableAnchors = [Anchor.CentreRight]; + } + + [BackgroundDependencyLoader] + private void load() + { + Debug.Assert(comboColours.Count > 0); + var hitObject = hasComboInformation as HitObject; + Debug.Assert(hitObject != null); + + FillFlowContainer container; + + Child = container = new FillFlowContainer + { + Width = 230, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + }; + + int selectedColourIndex = comboIndexFor(hasComboInformation, comboColours); + + for (int i = 0; i < comboColours.Count; i++) + { + int index = i; + + if (getPreviousHitObjectWithCombo(editorBeatmap, hitObject) is IHasComboInformation previousHasCombo + && index == comboIndexFor(previousHasCombo, comboColours) + && !canReuseLastComboColour(editorBeatmap, hitObject)) + { + continue; + } + + container.Add(new OsuClickableContainer + { + Size = new Vector2(50), + Masking = true, + CornerRadius = 25, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = comboColours[index], + }, + selectedColourIndex == index + ? new SpriteIcon + { + Icon = FontAwesome.Solid.Check, + Size = new Vector2(24), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = OsuColour.ForegroundTextColourFor(comboColours[index]), + } + : Empty() + }, + Action = () => + { + int comboDifference = index - selectedColourIndex; + if (comboDifference == 0) + return; + + int newOffset = hasComboInformation.ComboOffset + comboDifference; + // `newOffset` must be positive to serialise correctly - this implements the true math "modulus" rather than the built-in "remainder" % op + // which can return negative results when the first operand is negative + newOffset -= (int)Math.Floor((double)newOffset / comboColours.Count) * comboColours.Count; + + hasComboInformation.ComboOffset = newOffset; + editorBeatmap.BeginChange(); + editorBeatmap.Update((HitObject)hasComboInformation); + editorBeatmap.EndChange(); + this.HidePopover(); + } + }); + } + } + + private static IHasComboInformation? getPreviousHitObjectWithCombo(EditorBeatmap editorBeatmap, HitObject hitObject) + => editorBeatmap.HitObjects.TakeWhile(ho => ho != hitObject).LastOrDefault() as IHasComboInformation; + + private static bool canReuseLastComboColour(EditorBeatmap editorBeatmap, HitObject hitObject) + { + double? closestBreakEnd = editorBeatmap.Breaks.Select(b => b.EndTime) + .Where(t => t <= hitObject.StartTime) + .OrderBy(t => t) + .LastOrDefault(); + + if (closestBreakEnd == null) + return false; + + return editorBeatmap.HitObjects.FirstOrDefault(ho => ho.StartTime >= closestBreakEnd) == hitObject; + } + } + + // compare `EditorBeatmapSkin.updateColours()` et al. for reasoning behind the off-by-one index rotation + private static int comboIndexFor(IHasComboInformation hasComboInformation, IReadOnlyCollection comboColours) + => (hasComboInformation.ComboIndexWithOffsets + comboColours.Count - 1) % comboColours.Count; + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index bbb4095206..5d93c4ea9d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -237,22 +237,17 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A collection of states which will be displayed to the user in the toolbox. /// - public DrawableTernaryButton[] MainTernaryStates { get; private set; } + public Drawable[] MainTernaryStates { get; private set; } public SampleBankTernaryButton[] SampleBankTernaryStates { get; private set; } /// /// Create all ternary states required to be displayed to the user. /// - protected virtual IEnumerable CreateTernaryButtons() + protected virtual IEnumerable CreateTernaryButtons() { //TODO: this should only be enabled (visible?) for rulesets that provide combo-supporting HitObjects. - yield return new DrawableTernaryButton - { - Current = NewCombo, - Description = "New combo", - CreateIcon = () => new SpriteIcon { Icon = OsuIcon.EditorNewComboA }, - }; + yield return new NewComboTernaryButton { Current = NewCombo }; foreach (var kvp in SelectionHandler.SelectionSampleStates) { From 0d9a3428ae4b447d72e908f7fdb4f617525c0905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 10 Jan 2025 14:13:03 +0100 Subject: [PATCH 15/40] Merge conditionals --- .../Objects/CatchHitObject.cs | 21 ++++++++----------- osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs | 21 ++++++++----------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index 3c7ead09af..deaa566864 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs @@ -163,20 +163,17 @@ namespace osu.Game.Rulesets.Catch.Objects int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; - // For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so. - if (this is not BananaShower) + // - For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so. + // - At decode time, the first hitobject in the beatmap and the first hitobject after a banana shower are both enforced to be a new combo, + // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here. + if (this is not BananaShower && (NewCombo || lastObj == null || lastObj is BananaShower)) { - // At decode time, the first hitobject in the beatmap and the first hitobject after a banana shower are both enforced to be a new combo, - // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here. - if (NewCombo || lastObj == null || lastObj is BananaShower) - { - inCurrentCombo = 0; - index++; - indexWithOffsets += ComboOffset + 1; + inCurrentCombo = 0; + index++; + indexWithOffsets += ComboOffset + 1; - if (lastObj != null) - lastObj.LastInCombo = true; - } + if (lastObj != null) + lastObj.LastInCombo = true; } ComboIndex = index; diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 937e0bda23..9623d1999b 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -188,20 +188,17 @@ namespace osu.Game.Rulesets.Osu.Objects int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; - // For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so. - if (this is not Spinner) + // - For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so. + // - At decode time, the first hitobject in the beatmap and the first hitobject after a spinner are both enforced to be a new combo, + // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here. + if (this is not Spinner && (NewCombo || lastObj == null || lastObj is Spinner)) { - // At decode time, the first hitobject in the beatmap and the first hitobject after a spinner are both enforced to be a new combo, - // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here. - if (NewCombo || lastObj == null || lastObj is Spinner) - { - inCurrentCombo = 0; - index++; - indexWithOffsets += ComboOffset + 1; + inCurrentCombo = 0; + index++; + indexWithOffsets += ComboOffset + 1; - if (lastObj != null) - lastObj.LastInCombo = true; - } + if (lastObj != null) + lastObj.LastInCombo = true; } ComboIndex = index; From 94ea003d90f0d96ebe82ab1a80abb6e2672f060a Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sat, 11 Jan 2025 01:42:54 +0900 Subject: [PATCH 16/40] Update game `ScrollContainer` usage in line with framework changes See https://github.com/ppy/osu-framework/pull/6467. --- .../UserInterface/TestSceneSectionsContainer.cs | 2 +- osu.Game/Graphics/Containers/OsuScrollContainer.cs | 8 ++++---- osu.Game/Graphics/Containers/SectionsContainer.cs | 4 ++-- .../Containers/UserTrackingScrollContainer.cs | 4 ++-- osu.Game/Online/Leaderboards/Leaderboard.cs | 4 ++-- osu.Game/Overlays/Chat/ChannelScrollContainer.cs | 4 ++-- osu.Game/Overlays/Chat/DrawableChannel.cs | 2 +- osu.Game/Overlays/Mods/ModSelectOverlay.cs | 8 ++++---- osu.Game/Overlays/NewsOverlay.cs | 2 +- osu.Game/Overlays/OnlineOverlay.cs | 2 +- osu.Game/Overlays/OverlayScrollContainer.cs | 6 +++--- osu.Game/Overlays/WikiOverlay.cs | 2 +- .../Edit/Compose/Components/Timeline/Timeline.cs | 4 ++-- .../Components/Timeline/ZoomableScrollContainer.cs | 2 +- osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs | 6 +++--- osu.Game/Screens/Ranking/ScorePanelList.cs | 4 ++-- osu.Game/Screens/Select/BeatmapCarousel.cs | 12 ++++++------ 17 files changed, 38 insertions(+), 38 deletions(-) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs index 3a1eb554ab..7ec57c9e5e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs @@ -138,7 +138,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("section top is visible", () => { var scrollContainer = container.ChildrenOfType().Single(); - float sectionPosition = scrollContainer.GetChildPosInContent(container.Children[scrollIndex]); + double sectionPosition = scrollContainer.GetChildPosInContent(container.Children[scrollIndex]); return scrollContainer.Current < sectionPosition; }); } diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index a3cd5a4902..f40c91e27e 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.cs @@ -59,11 +59,11 @@ namespace osu.Game.Graphics.Containers /// An added amount to scroll beyond the requirement to bring the target into view. public void ScrollIntoView(Drawable d, bool animated = true, float extraScroll = 0) { - float childPos0 = GetChildPosInContent(d); - float childPos1 = GetChildPosInContent(d, d.DrawSize); + double childPos0 = GetChildPosInContent(d); + double childPos1 = GetChildPosInContent(d, d.DrawSize); - float minPos = Math.Min(childPos0, childPos1); - float maxPos = Math.Max(childPos0, childPos1); + double minPos = Math.Min(childPos0, childPos1); + double maxPos = Math.Max(childPos0, childPos1); if (minPos < Current || (minPos > Current && d.DrawSize[ScrollDim] > DisplayableContent)) ScrollTo(minPos - extraScroll, animated); diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs index 9f41c4eff2..828fc9704c 100644 --- a/osu.Game/Graphics/Containers/SectionsContainer.cs +++ b/osu.Game/Graphics/Containers/SectionsContainer.cs @@ -208,7 +208,7 @@ namespace osu.Game.Graphics.Containers private float getScrollTargetForDrawable(Drawable target) { // implementation similar to ScrollIntoView but a bit more nuanced. - return scrollContainer.GetChildPosInContent(target) - scrollContainer.DisplayableContent * scroll_y_centre; + return (float)(scrollContainer.GetChildPosInContent(target) - scrollContainer.DisplayableContent * scroll_y_centre); } public void ScrollToTop() => scrollContainer.ScrollTo(0); @@ -259,7 +259,7 @@ namespace osu.Game.Graphics.Containers updateSectionsMargin(); } - float currentScroll = scrollContainer.Current; + float currentScroll = (float)scrollContainer.Current; if (currentScroll != lastKnownScroll) { diff --git a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs index 354a57b7d2..30b9eeb74c 100644 --- a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs +++ b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs @@ -35,7 +35,7 @@ namespace osu.Game.Graphics.Containers { } - protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) + protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = default) { UserScrolling = true; base.OnUserScroll(value, animated, distanceDecay); @@ -53,7 +53,7 @@ namespace osu.Game.Graphics.Containers base.ScrollFromMouseEvent(e); } - public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) + public new void ScrollTo(double value, bool animated = true, double? distanceDecay = null) { UserScrolling = false; base.ScrollTo(value, animated, distanceDecay); diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index d76da54adf..3c25d6f789 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -375,8 +375,8 @@ namespace osu.Game.Online.Leaderboards { base.UpdateAfterChildren(); - float fadeBottom = scrollContainer.Current + scrollContainer.DrawHeight; - float fadeTop = scrollContainer.Current + LeaderboardScore.HEIGHT; + float fadeBottom = (float)(scrollContainer.Current + scrollContainer.DrawHeight); + float fadeTop = (float)(scrollContainer.Current + LeaderboardScore.HEIGHT); if (!scrollContainer.IsScrolledToEnd()) fadeBottom -= LeaderboardScore.HEIGHT; diff --git a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs index 6d8b21a7c5..b621b555b0 100644 --- a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs +++ b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs @@ -41,13 +41,13 @@ namespace osu.Game.Overlays.Chat #region Scroll handling - protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = null) + protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = null) { base.OnUserScroll(value, animated, distanceDecay); updateTrackState(); } - public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) + public new void ScrollTo(double value, bool animated = true, double? distanceDecay = null) { base.ScrollTo(value, animated, distanceDecay); updateTrackState(); diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index cb7cd03584..2f0461eb40 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -117,7 +117,7 @@ namespace osu.Game.Overlays.Chat if (chatLine == null) return; - float center = scroll.GetChildPosInContent(chatLine, chatLine.DrawSize / 2) - scroll.DisplayableContent / 2; + double center = scroll.GetChildPosInContent(chatLine, chatLine.DrawSize / 2) - scroll.DisplayableContent / 2; scroll.ScrollTo(Math.Clamp(center, 0, scroll.ScrollableExtent)); chatLine.Highlight(); diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index ed73340eeb..daac925dfb 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -710,13 +710,13 @@ namespace osu.Game.Overlays.Mods // the bounds below represent the horizontal range of scroll items to be considered fully visible/active, in the scroll's internal coordinate space. // note that clamping is applied to the left scroll bound to ensure scrolling past extents does not change the set of active columns. - float leftVisibleBound = Math.Clamp(Current, 0, ScrollableExtent); - float rightVisibleBound = leftVisibleBound + DrawWidth; + double leftVisibleBound = Math.Clamp(Current, 0, ScrollableExtent); + double rightVisibleBound = leftVisibleBound + DrawWidth; // if a movement is occurring at this time, the bounds below represent the full range of columns that the scroll movement will encompass. // this will be used to ensure that columns do not change state from active to inactive back and forth until they are fully scrolled past. - float leftMovementBound = Math.Min(Current, Target); - float rightMovementBound = Math.Max(Current, Target) + DrawWidth; + double leftMovementBound = Math.Min(Current, Target); + double rightMovementBound = Math.Max(Current, Target) + DrawWidth; foreach (var column in Child) { diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs index cb9d940a05..81ac67bd89 100644 --- a/osu.Game/Overlays/NewsOverlay.cs +++ b/osu.Game/Overlays/NewsOverlay.cs @@ -136,7 +136,7 @@ namespace osu.Game.Overlays { base.UpdateAfterChildren(); sidebarContainer.Height = DrawHeight; - sidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); + sidebarContainer.Y = (float)Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); } private void loadListing(int? year = null) diff --git a/osu.Game/Overlays/OnlineOverlay.cs b/osu.Game/Overlays/OnlineOverlay.cs index 051873b394..cc5a1b9d2d 100644 --- a/osu.Game/Overlays/OnlineOverlay.cs +++ b/osu.Game/Overlays/OnlineOverlay.cs @@ -88,7 +88,7 @@ namespace osu.Game.Overlays base.UpdateAfterChildren(); // don't block header by applying padding equal to the visible header height - loadingContainer.Padding = new MarginPadding { Top = Math.Max(0, Header.Height - ScrollFlow.Current) }; + loadingContainer.Padding = new MarginPadding { Top = (float)Math.Max(0, Header.Height - ScrollFlow.Current) }; } } } diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs index 4328977a8d..66a8686a88 100644 --- a/osu.Game/Overlays/OverlayScrollContainer.cs +++ b/osu.Game/Overlays/OverlayScrollContainer.cs @@ -35,7 +35,7 @@ namespace osu.Game.Overlays public ScrollBackButton Button { get; private set; } - private readonly Bindable lastScrollTarget = new Bindable(); + private readonly Bindable lastScrollTarget = new Bindable(); [BackgroundDependencyLoader] private void load() @@ -63,7 +63,7 @@ namespace osu.Game.Overlays Button.State = Target > button_scroll_position || lastScrollTarget.Value != null ? Visibility.Visible : Visibility.Hidden; } - protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) + protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = default) { base.OnUserScroll(value, animated, distanceDecay); @@ -112,7 +112,7 @@ namespace osu.Game.Overlays private readonly Box background; private readonly SpriteIcon spriteIcon; - public Bindable LastScrollTarget = new Bindable(); + public Bindable LastScrollTarget = new Bindable(); protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index 14a25a909d..ef258da82b 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -100,7 +100,7 @@ namespace osu.Game.Overlays if (articlePage != null) { articlePage.SidebarContainer.Height = DrawHeight; - articlePage.SidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); + articlePage.SidebarContainer.Y = (float)Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index e5360e2eeb..5f46b3d937 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -55,7 +55,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// /// The timeline's scroll position in the last frame. /// - private float lastScrollPosition; + private double lastScrollPosition; /// /// The track time in the last frame. @@ -322,7 +322,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// public double VisibleRange => editorClock.TrackLength / Zoom; - public double TimeAtPosition(float x) + public double TimeAtPosition(double x) { return x / Content.DrawWidth * editorClock.TrackLength; } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs index 31a0936eb4..9db14ce4c4 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs @@ -182,7 +182,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } private void transformZoomTo(float newZoom, float focusPoint, double duration = 0, Easing easing = Easing.None) - => this.TransformTo(this.PopulateTransform(new TransformZoom(focusPoint, zoomedContent.DrawWidth, Current), newZoom, duration, easing)); + => this.TransformTo(this.PopulateTransform(new TransformZoom(focusPoint, zoomedContent.DrawWidth, (float)Current), newZoom, duration, easing)); /// /// Invoked when has changed. diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs index d2b6b834f8..f6694505dc 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs @@ -114,15 +114,15 @@ namespace osu.Game.Screens.Play.HUD if (requiresScroll && TrackedScore != null) { - float scrollTarget = scroll.GetChildPosInContent(TrackedScore) + TrackedScore.DrawHeight / 2 - scroll.DrawHeight / 2; + double scrollTarget = scroll.GetChildPosInContent(TrackedScore) + TrackedScore.DrawHeight / 2 - scroll.DrawHeight / 2; scroll.ScrollTo(scrollTarget); } const float panel_height = GameplayLeaderboardScore.PANEL_HEIGHT; - float fadeBottom = scroll.Current + scroll.DrawHeight; - float fadeTop = scroll.Current + panel_height; + float fadeBottom = (float)(scroll.Current + scroll.DrawHeight); + float fadeTop = (float)(scroll.Current + panel_height); if (scroll.IsScrolledToStart()) fadeTop -= panel_height; if (!scroll.IsScrolledToEnd()) fadeBottom -= panel_height; diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index e711bed729..b0e1c89121 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -334,7 +334,7 @@ namespace osu.Game.Screens.Ranking private partial class Scroll : OsuScrollContainer { - public new float Target => base.Target; + public new double Target => base.Target; public Scroll() : base(Direction.Horizontal) @@ -344,7 +344,7 @@ namespace osu.Game.Screens.Ranking /// /// The target that will be scrolled to instantaneously next frame. /// - public float? InstantScrollTarget; + public double? InstantScrollTarget; protected override void UpdateAfterChildren() { diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 65c4133ea2..de12b36b17 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -611,12 +611,12 @@ namespace osu.Game.Screens.Select /// /// The position of the lower visible bound with respect to the current scroll position. /// - private float visibleBottomBound => Scroll.Current + DrawHeight + BleedBottom; + private float visibleBottomBound => (float)(Scroll.Current + DrawHeight + BleedBottom); /// /// The position of the upper visible bound with respect to the current scroll position. /// - private float visibleUpperBound => Scroll.Current - BleedTop; + private float visibleUpperBound => (float)(Scroll.Current - BleedTop); public void FlushPendingFilterOperations() { @@ -1006,7 +1006,7 @@ namespace osu.Game.Screens.Select // we take the difference in scroll height and apply to all visible panels. // this avoids edge cases like when the visible panels is reduced suddenly, causing ScrollContainer // to enter clamp-special-case mode where it animates completely differently to normal. - float scrollChange = scrollTarget.Value - Scroll.Current; + float scrollChange = (float)(scrollTarget.Value - Scroll.Current); Scroll.ScrollTo(scrollTarget.Value, false); foreach (var i in Scroll) i.Y += scrollChange; @@ -1217,12 +1217,12 @@ namespace osu.Game.Screens.Select private const float top_padding = 10; private const float bottom_padding = 70; - protected override float ToScrollbarPosition(float scrollPosition) + protected override float ToScrollbarPosition(double scrollPosition) { if (Precision.AlmostEquals(0, ScrollableExtent)) return 0; - return top_padding + (ScrollbarMovementExtent - (top_padding + bottom_padding)) * (scrollPosition / ScrollableExtent); + return (float)(top_padding + (ScrollbarMovementExtent - (top_padding + bottom_padding)) * (scrollPosition / ScrollableExtent)); } protected override float FromScrollbarPosition(float scrollbarPosition) @@ -1230,7 +1230,7 @@ namespace osu.Game.Screens.Select if (Precision.AlmostEquals(0, ScrollbarMovementExtent)) return 0; - return ScrollableExtent * ((scrollbarPosition - top_padding) / (ScrollbarMovementExtent - (top_padding + bottom_padding))); + return (float)(ScrollableExtent * ((scrollbarPosition - top_padding) / (ScrollbarMovementExtent - (top_padding + bottom_padding)))); } } } From dfbc93c3dc99653bb221bc07e3647402505bb676 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sun, 12 Jan 2025 19:16:53 +0900 Subject: [PATCH 17/40] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 32c43995f4..d87ca31f72 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 very soon to se don't have to live with this limitation. +**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 so we don't have to live with this limitation. ## Developing a custom ruleset From 76e09586fd3951b7659d67bf1aefaa5a8cfbecb2 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Sun, 12 Jan 2025 23:33:04 +0000 Subject: [PATCH 18/40] Fix possible nullref in `handleIntent()` Could happen if we get a malformed intent without data --- osu.Android/OsuGameActivity.cs | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index bbee491d90..fe11672767 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -13,7 +13,6 @@ using Android.Graphics; using Android.OS; using Android.Views; using osu.Framework.Android; -using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Database; using Debug = System.Diagnostics.Debug; using Uri = Android.Net.Uri; @@ -95,25 +94,38 @@ namespace osu.Android private void handleIntent(Intent? intent) { - switch (intent?.Action) + if (intent == null) + return; + + switch (intent.Action) { case Intent.ActionDefault: if (intent.Scheme == ContentResolver.SchemeContent) - handleImportFromUris(intent.Data.AsNonNull()); + { + if (intent.Data != null) + handleImportFromUris(intent.Data); + } else if (osu_url_schemes.Contains(intent.Scheme)) - game.HandleLink(intent.DataString); + { + if (intent.DataString != null) + game.HandleLink(intent.DataString); + } + break; case Intent.ActionSend: case Intent.ActionSendMultiple: { + if (intent.ClipData == null) + break; + var uris = new List(); - for (int i = 0; i < intent.ClipData?.ItemCount; i++) + for (int i = 0; i < intent.ClipData.ItemCount; i++) { - var content = intent.ClipData?.GetItemAt(i); - if (content != null) - uris.Add(content.Uri.AsNonNull()); + var item = intent.ClipData.GetItemAt(i); + if (item?.Uri != null) + uris.Add(item.Uri); } handleImportFromUris(uris.ToArray()); From b0339a9d63252a56cea9a1ec1da187a530419183 Mon Sep 17 00:00:00 2001 From: Susko3 Date: Mon, 13 Jan 2025 00:47:52 +0000 Subject: [PATCH 19/40] Create game as soon as possible --- osu.Android/OsuGameActivity.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index fe11672767..42065e61fd 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -49,9 +49,23 @@ namespace osu.Android /// Adjusted on startup to match expected UX for the current device type (phone/tablet). public ScreenOrientation DefaultOrientation = ScreenOrientation.Unspecified; - private OsuGameAndroid game = null!; + private readonly OsuGameAndroid game; - protected override Framework.Game CreateGame() => game = new OsuGameAndroid(this); + private bool gameCreated; + + protected override Framework.Game CreateGame() + { + if (gameCreated) + throw new InvalidOperationException("Framework tried to create a game twice."); + + gameCreated = true; + return game; + } + + public OsuGameActivity() + { + game = new OsuGameAndroid(this); + } protected override void OnCreate(Bundle? savedInstanceState) { From fc069e060c69599285dcba82c657b2568c399674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 13 Jan 2025 12:38:28 +0100 Subject: [PATCH 20/40] Only show colour on new combo selector button if overridden As proposed in https://discord.com/channels/188630481301012481/188630652340404224/1327309179911929936. --- .../Edit/Components/TernaryButtons/NewComboTernaryButton.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs index effe35c0c3..8c64480b43 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs @@ -147,19 +147,19 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons private void updateState() { - if (SelectedHitObject.Value == null) + Enabled.Value = SelectedHitObject.Value != null; + + if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0) { BackgroundColour = colourProvider.Background3; icon.Colour = BackgroundColour.Darken(0.5f); icon.Blending = BlendingParameters.Additive; - Enabled.Value = false; } else { BackgroundColour = ComboColours[comboIndexFor(SelectedHitObject.Value, ComboColours)]; icon.Colour = OsuColour.ForegroundTextColourFor(BackgroundColour); icon.Blending = BlendingParameters.Inherit; - Enabled.Value = true; } } From 39a69d64548de357b2c408da774783f463d727ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 13 Jan 2025 13:04:17 +0100 Subject: [PATCH 21/40] Adjust test to pass What I think was happening here is that the dump of the accuracy counter's state was happening too early. The component is loaded synchronously into the `ISerialisableDrawableContainer` before its default position is set via the "apply defaults" `ArgonSkin` flow - so the test needs to wait for that to take place first. --- .../Visual/Navigation/TestSceneSkinEditorNavigation.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs index b319c88fc2..622c85774a 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs @@ -120,7 +120,7 @@ namespace osu.Game.Tests.Visual.Navigation string state = string.Empty; - AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType().Any(counter => counter.IsLoaded)); + AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType().Any(counter => counter.Position != new Vector2())); AddStep("dump state of accuracy meter", () => state = JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo())); AddStep("add any component", () => Game.ChildrenOfType().First().TriggerClick()); AddStep("undo", () => @@ -157,7 +157,7 @@ namespace osu.Game.Tests.Visual.Navigation string state = string.Empty; - AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType().Any(counter => counter.IsLoaded)); + AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType().Any(counter => counter.Position != new Vector2())); AddStep("dump state of accuracy meter", () => state = JsonConvert.SerializeObject(Game.ChildrenOfType().First().CreateSerialisedInfo())); AddStep("add any component", () => Game.ChildrenOfType().First().TriggerClick()); AddStep("undo", () => From 7761a0c18a3080f49e6c7dda9bc467005af625a3 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 14 Jan 2025 16:15:43 +0900 Subject: [PATCH 22/40] Add failing test coverage showing storyboard not being updated when dimmed --- .../Background/TestSceneUserDimBackgrounds.cs | 61 ++++++++++++++++--- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 693e1e48d4..96954f6984 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -3,6 +3,7 @@ #nullable disable +using System.Linq; using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; @@ -15,6 +16,7 @@ using osu.Framework.Input.Events; using osu.Framework.Input.States; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -31,6 +33,7 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; +using osu.Game.Storyboards.Drawables; using osu.Game.Tests.Resources; using osuTK; using osuTK.Graphics; @@ -45,6 +48,7 @@ namespace osu.Game.Tests.Visual.Background private LoadBlockingTestPlayer player; private BeatmapManager manager; private RulesetStore rulesets; + private UpdateCounter storyboardUpdateCounter; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -194,6 +198,28 @@ namespace osu.Game.Tests.Visual.Background AddUntilStep("Storyboard is visible", () => player.IsStoryboardVisible); } + [Test] + public void TestStoryboardUpdatesWhenDimmed() + { + performFullSetup(); + createFakeStoryboard(); + + AddStep("Enable fully dimmed storyboard", () => + { + player.StoryboardReplacesBackground.Value = true; + player.StoryboardEnabled.Value = true; + player.DimmableStoryboard.IgnoreUserSettings.Value = false; + songSelect.DimLevel.Value = 1f; + }); + + AddUntilStep("Storyboard is invisible", () => !player.IsStoryboardVisible); + + AddWaitStep("wait some", 20); + + AddUntilStep("Storyboard is always present", () => player.ChildrenOfType().Single().AlwaysPresent, () => Is.True); + AddUntilStep("Dimmable storyboard content is being updated", () => storyboardUpdateCounter.StoryboardContentLastUpdated, () => Is.EqualTo(Time.Current).Within(100)); + } + [Test] public void TestStoryboardIgnoreUserSettings() { @@ -269,15 +295,19 @@ namespace osu.Game.Tests.Visual.Background { player.StoryboardEnabled.Value = false; player.StoryboardReplacesBackground.Value = false; - player.DimmableStoryboard.Add(new OsuSpriteText + player.DimmableStoryboard.AddRange(new Drawable[] { - Size = new Vector2(500, 50), - Alpha = 1, - Colour = Color4.White, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "THIS IS A STORYBOARD", - Font = new FontUsage(size: 50) + storyboardUpdateCounter = new UpdateCounter(), + new OsuSpriteText + { + Size = new Vector2(500, 50), + Alpha = 1, + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "THIS IS A STORYBOARD", + Font = new FontUsage(size: 50) + } }); }); @@ -353,7 +383,7 @@ namespace osu.Game.Tests.Visual.Background /// /// Make sure every time a screen gets pushed, the background doesn't get replaced /// - /// Whether or not the original background (The one created in DummySongSelect) is still the current background + /// Whether the original background (The one created in DummySongSelect) is still the current background public bool IsBackgroundCurrent() => background?.IsCurrentScreen() == true; } @@ -384,7 +414,7 @@ namespace osu.Game.Tests.Visual.Background public new DimmableStoryboard DimmableStoryboard => base.DimmableStoryboard; - // Whether or not the player should be allowed to load. + // Whether the player should be allowed to load. public bool BlockLoad; public Bindable StoryboardEnabled; @@ -451,6 +481,17 @@ namespace osu.Game.Tests.Visual.Background } } + private class UpdateCounter : Drawable + { + public double StoryboardContentLastUpdated; + + protected override void Update() + { + base.Update(); + StoryboardContentLastUpdated = Time.Current; + } + } + private partial class TestDimmableBackground : BackgroundScreenBeatmap.DimmableBackground { public Color4 CurrentColour => Content.Colour; From 77db35580900896fa46fca26b45780c21727e3af Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 14 Jan 2025 15:55:29 +0900 Subject: [PATCH 23/40] Ensure storyboards are still updated even when dim is 100% This avoids piled-up overhead when entering break time. It's not great, but it is what we need for now to avoid weirdness. --- osu.Game/Screens/Play/DimmableStoryboard.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs index 84d99ea863..a096400fe0 100644 --- a/osu.Game/Screens/Play/DimmableStoryboard.cs +++ b/osu.Game/Screens/Play/DimmableStoryboard.cs @@ -69,7 +69,22 @@ namespace osu.Game.Screens.Play protected override void LoadComplete() { - ShowStoryboard.BindValueChanged(_ => initializeStoryboard(true), true); + ShowStoryboard.BindValueChanged(show => + { + initializeStoryboard(true); + + if (drawableStoryboard != null) + { + // Regardless of user dim setting, for the time being we need to ensure storyboards are still updated in the background (even if not displayed). + // If we don't do this, an intensive storyboard will have a lot of catch-up work to do at the start of a break, causing a huge stutter. + // + // This can be reconsidered when https://github.com/ppy/osu-framework/issues/6491 is resolved. + bool alwaysPresent = show.NewValue; + + Content.AlwaysPresent = alwaysPresent; + drawableStoryboard.AlwaysPresent = alwaysPresent; + } + }, true); base.LoadComplete(); } From 2c57cd59a5cbbb4c9d95a70e25a7d64d0bd3d9cd Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 14 Jan 2025 16:26:56 +0900 Subject: [PATCH 24/40] Update framework --- osu.Android.props | 2 +- osu.Game/osu.Game.csproj | 2 +- osu.iOS.props | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Android.props b/osu.Android.props index 84827ce76b..dbb0a6d610 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + diff --git a/osu.iOS.props b/osu.iOS.props index 349d6fa1d7..afbcf49d32 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - + From 904a08af26b2c0ba9992365de56c6bb2f2a12a68 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 14 Jan 2025 16:29:56 +0900 Subject: [PATCH 25/40] Update textbox usage in line with framework changes --- osu.Game/Graphics/UserInterface/OsuNumberBox.cs | 6 ++++-- osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs | 6 +++--- osu.Game/Overlays/Settings/SettingsNumberBox.cs | 6 +++++- osu.Game/Screens/Edit/Setup/MetadataSection.cs | 6 +++++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs index db4b7b2ab3..1742cb6bdd 100644 --- a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs @@ -1,14 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Input; + namespace osu.Game.Graphics.UserInterface { public partial class OsuNumberBox : OsuTextBox { - protected override bool AllowIme => false; - public OsuNumberBox() { + InputProperties = new TextInputProperties(TextInputType.Number, false); + SelectAllOnFocus = true; } diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index 0be7b4dc48..e2e273cfe1 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -18,7 +18,7 @@ using osu.Game.Localisation; namespace osu.Game.Graphics.UserInterface { - public partial class OsuPasswordTextBox : OsuTextBox, ISuppressKeyEventLogging + public partial class OsuPasswordTextBox : OsuTextBox { protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer { @@ -32,8 +32,6 @@ namespace osu.Game.Graphics.UserInterface protected override bool AllowWordNavigation => false; - protected override bool AllowIme => false; - private readonly CapsWarning warning; [Resolved] @@ -41,6 +39,8 @@ namespace osu.Game.Graphics.UserInterface public OsuPasswordTextBox() { + InputProperties = new TextInputProperties(TextInputType.Password, false); + Add(warning = new CapsWarning { Size = new Vector2(20), diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index fbcdb4a968..2548f3c87b 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -5,6 +5,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; namespace osu.Game.Overlays.Settings { @@ -66,7 +67,10 @@ namespace osu.Game.Overlays.Settings private partial class OutlinedNumberBox : OutlinedTextBox { - protected override bool AllowIme => false; + public OutlinedNumberBox() + { + InputProperties = new TextInputProperties(TextInputType.Number, false); + } protected override bool CanAddCharacter(char character) => char.IsAsciiDigit(character); diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 7b74aa7642..85247bc15a 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -4,6 +4,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; @@ -136,7 +137,10 @@ namespace osu.Game.Screens.Edit.Setup private partial class RomanisedTextBox : InnerTextBox { - protected override bool AllowIme => false; + public RomanisedTextBox() + { + InputProperties = new TextInputProperties(TextInputType.Text, false); + } protected override bool CanAddCharacter(char character) => MetadataUtils.IsRomanised(character); From 8ffd2547196d89123cb51566418f2aaa012f9793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 14 Jan 2025 08:54:40 +0100 Subject: [PATCH 26/40] Adjust initialisation code to start with combo colour picker hidden --- .../Edit/Components/TernaryButtons/NewComboTernaryButton.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs index 8c64480b43..1f95d5f239 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs @@ -54,7 +54,6 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Right = 30 }, Child = new DrawableTernaryButton { Current = Current, @@ -66,6 +65,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, + Alpha = 0, Width = 25, ComboColours = { BindTarget = comboColours } } From 058ff8af7769cbc50438d0d6078b51c5902564fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 14 Jan 2025 09:22:56 +0100 Subject: [PATCH 27/40] Make test class partial --- osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 96954f6984..eeaa68e2ee 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -481,7 +481,7 @@ namespace osu.Game.Tests.Visual.Background } } - private class UpdateCounter : Drawable + private partial class UpdateCounter : Drawable { public double StoryboardContentLastUpdated; From f6073d4ac09c499d7b828d01a7d04671fc252563 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 14 Jan 2025 17:43:29 +0900 Subject: [PATCH 28/40] Ensure API starts up with `LocalUser` in correct state I noticed in passing that in a very edge case scenario where the API's `run` thread doesn't run before it is loaded into the game, something could access it and get a guest `LocalUser` when the local user actually has a valid login. Put another way, the `protected HasLogin` could be `true` while `LocalUser` is `Guest`. I think we want to avoid this, so I've moved the initial set of the local user earlier in the initialisation process. If this is controversial in any way, the PR can be closed and we can assume no one is ever going to run into this scenario (or that it doesn't matter enough even if they did). --- osu.Game/Online/API/APIAccess.cs | 43 ++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index ec48fa2436..e0927dbc4e 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -13,6 +13,7 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using osu.Framework.Bindables; +using osu.Framework.Development; using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -110,6 +111,9 @@ namespace osu.Game.Online.API config.BindWith(OsuSetting.UserOnlineStatus, configStatus); + // Early call to ensure the local user / "logged in" state is correct immediately. + setPlaceholderLocalUser(); + localUser.BindValueChanged(u => { u.OldValue?.Activity.UnbindFrom(activity); @@ -193,7 +197,7 @@ namespace osu.Game.Online.API Debug.Assert(HasLogin); - // Ensure that we are in an online state. If not, attempt a connect. + // Ensure that we are in an online state. If not, attempt to connect. if (state.Value != APIState.Online) { attemptConnect(); @@ -247,17 +251,7 @@ namespace osu.Game.Online.API /// Whether the connection attempt was successful. private void attemptConnect() { - if (localUser.IsDefault) - { - // Show a placeholder user if saved credentials are available. - // This is useful for storing local scores and showing a placeholder username after starting the game, - // until a valid connection has been established. - setLocalUser(new APIUser - { - Username = ProvidedUsername, - Status = { Value = configStatus.Value ?? UserStatus.Online } - }); - } + Scheduler.Add(setPlaceholderLocalUser, false); // save the username at this point, if the user requested for it to be. config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); @@ -339,9 +333,11 @@ namespace osu.Game.Online.API userReq.Success += me => { + Debug.Assert(ThreadSafety.IsUpdateThread); + me.Status.Value = configStatus.Value ?? UserStatus.Online; - setLocalUser(me); + localUser.Value = me; state.Value = me.SessionVerified ? APIState.Online : APIState.RequiresSecondFactorAuth; failureCount = 0; @@ -366,6 +362,23 @@ namespace osu.Game.Online.API Thread.Sleep(500); } + /// + /// Show a placeholder user if saved credentials are available. + /// This is useful for storing local scores and showing a placeholder username after starting the game, + /// until a valid connection has been established. + /// + private void setPlaceholderLocalUser() + { + if (!localUser.IsDefault) + return; + + localUser.Value = new APIUser + { + Username = ProvidedUsername, + Status = { Value = configStatus.Value ?? UserStatus.Online } + }; + } + public void Perform(APIRequest request) { try @@ -593,7 +606,7 @@ namespace osu.Game.Online.API // Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present Schedule(() => { - setLocalUser(createGuestUser()); + localUser.Value = createGuestUser(); friends.Clear(); }); @@ -619,8 +632,6 @@ namespace osu.Game.Online.API private static APIUser createGuestUser() => new GuestUser(); - private void setLocalUser(APIUser user) => Scheduler.Add(() => localUser.Value = user, false); - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); From 51c7c218bfc83c8b45c7b1853485877c6a7504dd Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 14 Jan 2025 17:51:04 +0900 Subject: [PATCH 29/40] Simplify operations on local list --- osu.Game/Online/API/APIAccess.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 46476ab7f0..9d0ef06ebf 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -612,14 +612,14 @@ namespace osu.Game.Online.API friendsReq.Failure += _ => state.Value = APIState.Failing; friendsReq.Success += res => { - // Add new friends into local list. - HashSet friendsSet = friends.Select(f => f.TargetID).ToHashSet(); - friends.AddRange(res.Where(f => !friendsSet.Contains(f.TargetID))); + var existingFriends = friends.Select(f => f.TargetID).ToHashSet(); + var updatedFriends = res.Select(f => f.TargetID).ToHashSet(); - // Remove non-friends from local lists. - friendsSet.Clear(); - friendsSet.AddRange(res.Select(f => f.TargetID)); - friends.RemoveAll(f => !friendsSet.Contains(f.TargetID)); + // Add new friends into local list. + friends.AddRange(res.Where(r => !existingFriends.Contains(r.TargetID))); + + // Remove non-friends from local list. + friends.RemoveAll(f => !updatedFriends.Contains(f.TargetID)); }; Queue(friendsReq); From 156207d3472541422fe3b57fec0f05435b684e7f Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 14 Jan 2025 17:54:40 +0900 Subject: [PATCH 30/40] Remove unused using --- osu.Game/Online/API/APIAccess.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 9d0ef06ebf..d44ca90fa1 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -19,7 +19,6 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Configuration; -using osu.Game.Extensions; using osu.Game.Localisation; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; From 55ae0403d8ee2f4b37f78a4f9fcf185443d50832 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 14 Jan 2025 18:18:53 +0900 Subject: [PATCH 31/40] Ensure API state is `Connecting` immediately on startup when credentials are present Currently, there's a period where the API is `Offline` even though it is about to connect (as soon as the `run` thread starts up). This can cause any `Queue`d requests to fail if they arrive too early. To avoid this, let's ensure the `Connecting` state is set as early as possible. --- osu.Game/Online/API/APIAccess.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index e0927dbc4e..49ba99daa9 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -111,8 +111,14 @@ namespace osu.Game.Online.API config.BindWith(OsuSetting.UserOnlineStatus, configStatus); - // Early call to ensure the local user / "logged in" state is correct immediately. - setPlaceholderLocalUser(); + if (HasLogin) + { + // Early call to ensure the local user / "logged in" state is correct immediately. + prepareForConnect(); + + // This is required so that Queue() requests during startup sequence don't fail due to "not logged in". + state.Value = APIState.Connecting; + } localUser.BindValueChanged(u => { @@ -251,7 +257,7 @@ namespace osu.Game.Online.API /// Whether the connection attempt was successful. private void attemptConnect() { - Scheduler.Add(setPlaceholderLocalUser, false); + Scheduler.Add(prepareForConnect, false); // save the username at this point, if the user requested for it to be. config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); @@ -367,7 +373,7 @@ namespace osu.Game.Online.API /// This is useful for storing local scores and showing a placeholder username after starting the game, /// until a valid connection has been established. /// - private void setPlaceholderLocalUser() + private void prepareForConnect() { if (!localUser.IsDefault) return; From 3ddff1933738c17911514306734c2f266b618a28 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 14 Jan 2025 19:03:58 +0900 Subject: [PATCH 32/40] Fix potential nullref due to silly null handling and too much OOP --- osu.Game/Storyboards/Drawables/DrawableStoryboard.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index 095bd95314..5ef6b30a82 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -35,7 +35,7 @@ namespace osu.Game.Storyboards.Drawables protected override Container Content { get; } - protected override Vector2 DrawScale => new Vector2(Parent!.DrawHeight / 480); + protected override Vector2 DrawScale => new Vector2((Parent?.DrawHeight ?? 0) / 480); public override bool RemoveCompletedTransforms => false; From 91fa2e70d8e7d49d7143f62a393e68324f2fe7b0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 14 Jan 2025 20:41:18 +0900 Subject: [PATCH 33/40] Revert name change --- osu.Game/Online/API/APIAccess.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 1f9dffc605..00fe3bb005 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -115,7 +115,7 @@ namespace osu.Game.Online.API if (HasLogin) { // Early call to ensure the local user / "logged in" state is correct immediately. - prepareForConnect(); + setPlaceholderLocalUser(); // This is required so that Queue() requests during startup sequence don't fail due to "not logged in". state.Value = APIState.Connecting; @@ -258,7 +258,7 @@ namespace osu.Game.Online.API /// Whether the connection attempt was successful. private void attemptConnect() { - Scheduler.Add(prepareForConnect, false); + Scheduler.Add(setPlaceholderLocalUser, false); // save the username at this point, if the user requested for it to be. config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); @@ -374,7 +374,7 @@ namespace osu.Game.Online.API /// This is useful for storing local scores and showing a placeholder username after starting the game, /// until a valid connection has been established. /// - private void prepareForConnect() + private void setPlaceholderLocalUser() { if (!localUser.IsDefault) return; From 208824e9f47de863860ac8a010cae9deabb0f20b Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Tue, 14 Jan 2025 21:40:14 +0300 Subject: [PATCH 34/40] Add ability for cursor trail to spin --- .../Skinning/Legacy/LegacyCursorTrail.cs | 1 + .../Skinning/OsuSkinConfiguration.cs | 1 + .../UI/Cursor/CursorTrail.cs | 22 +++++++++++++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs index ca0002d8c0..4c21b94326 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs @@ -34,6 +34,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private void load(OsuConfigManager config, ISkinSource skinSource) { cursorSize = config.GetBindable(OsuSetting.GameplayCursorSize).GetBoundCopy(); + Spin = skin.GetConfig(OsuSkinConfiguration.CursorTrailRotate)?.Value ?? true; Texture = skin.GetTexture("cursortrail"); diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs index 9685ab685d..81488ca1a3 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs @@ -9,6 +9,7 @@ namespace osu.Game.Rulesets.Osu.Skinning CursorCentre, CursorExpand, CursorRotate, + CursorTrailRotate, HitCircleOverlayAboveNumber, // ReSharper disable once IdentifierTypo diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 5132dc2859..920a8c372f 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -39,6 +39,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private IShader shader; private double timeOffset; private float time; + protected bool Spin { get; set; } /// /// The scale used on creation of a new trail part. @@ -220,6 +221,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private float time; private float fadeExponent; + private float angle; private readonly TrailPart[] parts = new TrailPart[max_sprites]; private Vector2 originPosition; @@ -239,6 +241,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor texture = Source.texture; time = Source.time; fadeExponent = Source.FadeExponent; + angle = Source.Spin ? time / 10 : 0; originPosition = Vector2.Zero; @@ -279,6 +282,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor renderer.PushLocalMatrix(DrawInfo.Matrix); + float sin = MathF.Sin(angle); + float cos = MathF.Cos(angle); + foreach (var part in parts) { if (part.InvalidationID == -1) @@ -289,7 +295,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), + Position = rotateAround(new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), part.Position, sin, cos), TexturePosition = textureRect.BottomLeft, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomLeft.Linear, @@ -298,7 +304,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), + Position = rotateAround(new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), part.Position, sin, cos), TexturePosition = textureRect.BottomRight, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomRight.Linear, @@ -307,7 +313,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), + Position = rotateAround(new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), part.Position, sin, cos), TexturePosition = textureRect.TopRight, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopRight.Linear, @@ -316,7 +322,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), + Position = rotateAround(new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), part.Position, sin, cos), TexturePosition = textureRect.TopLeft, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopLeft.Linear, @@ -330,6 +336,14 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor shader.Unbind(); } + private static Vector2 rotateAround(Vector2 input, Vector2 origin, float sin, float cos) + { + float xTranslated = input.X - origin.X; + float yTranslated = input.Y - origin.Y; + + return new Vector2(xTranslated * cos - yTranslated * sin, xTranslated * sin + yTranslated * cos) + origin; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); From 7a6355d7cfe61abaaf4167ecda84755f4da9c9a4 Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Tue, 14 Jan 2025 22:51:17 +0300 Subject: [PATCH 35/40] Sync cursor trail rotation with the cursor --- osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs | 4 +++- osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs index 375d81049d..e526c4f14c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs @@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public partial class LegacyCursor : SkinnableCursor { + public static readonly int REVOLUTION_DURATION = 10000; + private const float pressed_scale = 1.3f; private const float released_scale = 1f; @@ -52,7 +54,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy protected override void LoadComplete() { if (spin) - ExpandTarget.Spin(10000, RotationDirection.Clockwise); + ExpandTarget.Spin(REVOLUTION_DURATION, RotationDirection.Clockwise); } public override void Expand() diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 920a8c372f..5b7d2d40d3 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -18,6 +18,7 @@ using osu.Framework.Graphics.Visualisation; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Timing; +using osu.Game.Rulesets.Osu.Skinning.Legacy; using osuTK; using osuTK.Graphics; using osuTK.Graphics.ES30; @@ -79,9 +80,12 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor shader = shaders.Load(@"CursorTrail", FragmentShaderDescriptor.TEXTURE); } + private double loadCompleteTime; + protected override void LoadComplete() { base.LoadComplete(); + loadCompleteTime = Parent!.Clock.CurrentTime; // using parent's clock since our is overridden resetTime(); } @@ -241,7 +245,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor texture = Source.texture; time = Source.time; fadeExponent = Source.FadeExponent; - angle = Source.Spin ? time / 10 : 0; + // The goal is to sync trail rotation with the cursor. Cursor uses spin transform which starts rotation at LoadComplete time. + angle = Source.Spin ? (float)((Source.Parent!.Clock.CurrentTime - Source.loadCompleteTime) * 2 * Math.PI / LegacyCursor.REVOLUTION_DURATION) : 0; originPosition = Vector2.Zero; From 57a9911b22e29979f1bd55c16e1e911c8ab748a5 Mon Sep 17 00:00:00 2001 From: Rudi Herouard Date: Wed, 15 Jan 2025 04:12:54 +0100 Subject: [PATCH 36/40] Apply beatmap offset on every beatmap set difficulty if they have the same audio --- osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index f93fa1b3c5..ac224794ea 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -165,13 +165,14 @@ namespace osu.Game.Screens.Play.PlayerSettings if (setInfo == null) // only the case for tests. return; - // Apply to all difficulties in a beatmap set for now (they generally always share timing). + // Apply to all difficulties in a beatmap set if they have the same audio + // (they generally always share timing). foreach (var b in setInfo.Beatmaps) { BeatmapUserSettings userSettings = b.UserSettings; double val = Current.Value; - if (userSettings.Offset != val) + if (userSettings.Offset != val && b.AudioEquals(beatmap.Value.BeatmapInfo)) userSettings.Offset = val; } }); From 2eb63e6fe045f7e2b6087897669add86cc8932cf Mon Sep 17 00:00:00 2001 From: Andrei Zavatski Date: Wed, 15 Jan 2025 20:38:51 +0300 Subject: [PATCH 37/40] Simplify rotation sync with no clocks involved --- osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs | 8 ++------ osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs | 5 +++++ osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs | 3 +++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 5b7d2d40d3..7809a0bf05 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -18,7 +18,6 @@ using osu.Framework.Graphics.Visualisation; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Timing; -using osu.Game.Rulesets.Osu.Skinning.Legacy; using osuTK; using osuTK.Graphics; using osuTK.Graphics.ES30; @@ -41,6 +40,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private double timeOffset; private float time; protected bool Spin { get; set; } + public float PartRotation { get; set; } /// /// The scale used on creation of a new trail part. @@ -80,12 +80,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor shader = shaders.Load(@"CursorTrail", FragmentShaderDescriptor.TEXTURE); } - private double loadCompleteTime; - protected override void LoadComplete() { base.LoadComplete(); - loadCompleteTime = Parent!.Clock.CurrentTime; // using parent's clock since our is overridden resetTime(); } @@ -245,8 +242,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor texture = Source.texture; time = Source.time; fadeExponent = Source.FadeExponent; - // The goal is to sync trail rotation with the cursor. Cursor uses spin transform which starts rotation at LoadComplete time. - angle = Source.Spin ? (float)((Source.Parent!.Clock.CurrentTime - Source.loadCompleteTime) * 2 * Math.PI / LegacyCursor.REVOLUTION_DURATION) : 0; + angle = Source.Spin ? float.DegreesToRadians(Source.PartRotation) : 0; originPosition = Vector2.Zero; diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs index c2f7d84f5e..e84fb9e2d6 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs @@ -36,6 +36,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor /// public Vector2 CurrentExpandedScale => skinnableCursor.ExpandTarget?.Scale ?? Vector2.One; + /// + /// The current rotation of the cursor. + /// + public float CurrentRotation => skinnableCursor.ExpandTarget?.Rotation ?? 0; + public IBindable CursorScale => cursorScale; /// diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index 8c0871d54f..974d99d7c8 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -83,7 +83,10 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor base.Update(); if (cursorTrail.Drawable is CursorTrail trail) + { trail.NewPartScale = ActiveCursor.CurrentExpandedScale; + trail.PartRotation = ActiveCursor.CurrentRotation; + } } public bool OnPressed(KeyBindingPressEvent e) From 6008c3138ead169b6586dfaf481afa832cda3bc6 Mon Sep 17 00:00:00 2001 From: Shawn Presser Date: Wed, 15 Jan 2025 19:29:41 -0600 Subject: [PATCH 38/40] Typo fix --- osu.Game/Rulesets/Scoring/HitResult.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index b6cfca58db..46c0371d9f 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Scoring /// /// /// This miss window should determine how early a hit can be before it is considered for judgement (as opposed to being ignored as - /// "too far in the future). It should also define when a forced miss should be triggered (as a result of no user input in time). + /// "too far in the future"). It should also define when a forced miss should be triggered (as a result of no user input in time). /// [Description(@"Miss")] [EnumMember(Value = "miss")] From 920648c267484c4e57386bbc39bd3a83c6f9ac35 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 16 Jan 2025 14:00:27 +0900 Subject: [PATCH 39/40] Minor refactorings and xmldoc additions --- .../Skinning/Legacy/LegacyCursorTrail.cs | 2 +- .../UI/Cursor/CursorTrail.cs | 48 +++++++++++++------ 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs index 4c21b94326..375bef721d 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private void load(OsuConfigManager config, ISkinSource skinSource) { cursorSize = config.GetBindable(OsuSetting.GameplayCursorSize).GetBoundCopy(); - Spin = skin.GetConfig(OsuSkinConfiguration.CursorTrailRotate)?.Value ?? true; + AllowPartRotation = skin.GetConfig(OsuSkinConfiguration.CursorTrailRotate)?.Value ?? true; Texture = skin.GetTexture("cursortrail"); diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 7809a0bf05..1c2d69fa00 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -34,21 +34,24 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor /// protected virtual float FadeExponent => 1.7f; - private readonly TrailPart[] parts = new TrailPart[max_sprites]; - private int currentIndex; - private IShader shader; - private double timeOffset; - private float time; - protected bool Spin { get; set; } - public float PartRotation { get; set; } - /// /// The scale used on creation of a new trail part. /// - public Vector2 NewPartScale = Vector2.One; + public Vector2 NewPartScale { get; set; } = Vector2.One; - private Anchor trailOrigin = Anchor.Centre; + /// + /// The rotation (in degrees) to apply to trail parts when is true. + /// + public float PartRotation { get; set; } + /// + /// Whether to rotate trail parts based on the value of . + /// + protected bool AllowPartRotation { get; set; } + + /// + /// The trail part texture origin. + /// protected Anchor TrailOrigin { get => trailOrigin; @@ -59,6 +62,13 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor } } + private readonly TrailPart[] parts = new TrailPart[max_sprites]; + private Anchor trailOrigin = Anchor.Centre; + private int currentIndex; + private IShader shader; + private double timeOffset; + private float time; + public CursorTrail() { // as we are currently very dependent on having a running clock, let's make our own clock for the time being. @@ -242,7 +252,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor texture = Source.texture; time = Source.time; fadeExponent = Source.FadeExponent; - angle = Source.Spin ? float.DegreesToRadians(Source.PartRotation) : 0; + angle = Source.AllowPartRotation ? float.DegreesToRadians(Source.PartRotation) : 0; originPosition = Vector2.Zero; @@ -296,7 +306,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = rotateAround(new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), part.Position, sin, cos), + Position = rotateAround( + new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), + part.Position, sin, cos), TexturePosition = textureRect.BottomLeft, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomLeft.Linear, @@ -305,7 +317,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = rotateAround(new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), part.Position, sin, cos), + Position = rotateAround( + new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, + part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), part.Position, sin, cos), TexturePosition = textureRect.BottomRight, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomRight.Linear, @@ -314,7 +328,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = rotateAround(new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), part.Position, sin, cos), + Position = rotateAround( + new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), + part.Position, sin, cos), TexturePosition = textureRect.TopRight, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopRight.Linear, @@ -323,7 +339,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = rotateAround(new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), part.Position, sin, cos), + Position = rotateAround( + new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), + part.Position, sin, cos), TexturePosition = textureRect.TopLeft, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopLeft.Linear, From fe8389bc2b0a65c39351275f3db4e79b6afc514c Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 16 Jan 2025 14:11:21 +0900 Subject: [PATCH 40/40] Add test --- .../TestSceneCursorTrail.cs | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs index 17f365f820..a8a65f7edb 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; @@ -17,6 +18,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Framework.Testing.Input; using osu.Game.Audio; +using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Skinning; @@ -103,6 +105,23 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("contract", () => this.ChildrenOfType().Single().NewPartScale = Vector2.One); } + [Test] + public void TestRotation() + { + createTest(() => + { + var skinContainer = new LegacySkinContainer(renderer, provideMiddle: true, enableRotation: true); + var legacyCursorTrail = new LegacyRotatingCursorTrail(skinContainer) + { + NewPartScale = new Vector2(10) + }; + + skinContainer.Child = legacyCursorTrail; + + return skinContainer; + }); + } + private void createTest(Func createContent) => AddStep("create trail", () => { Clear(); @@ -121,12 +140,14 @@ namespace osu.Game.Rulesets.Osu.Tests private readonly IRenderer renderer; private readonly bool provideMiddle; private readonly bool provideCursor; + private readonly bool enableRotation; - public LegacySkinContainer(IRenderer renderer, bool provideMiddle, bool provideCursor = true) + public LegacySkinContainer(IRenderer renderer, bool provideMiddle, bool provideCursor = true, bool enableRotation = false) { this.renderer = renderer; this.provideMiddle = provideMiddle; this.provideCursor = provideCursor; + this.enableRotation = enableRotation; RelativeSizeAxes = Axes.Both; } @@ -152,7 +173,19 @@ namespace osu.Game.Rulesets.Osu.Tests public ISample GetSample(ISampleInfo sampleInfo) => null; - public IBindable GetConfig(TLookup lookup) => null; + public IBindable GetConfig(TLookup lookup) + { + switch (lookup) + { + case OsuSkinConfiguration osuLookup: + if (osuLookup == OsuSkinConfiguration.CursorTrailRotate) + return SkinUtils.As(new BindableBool(enableRotation)); + + break; + } + + return null; + } public ISkin FindProvider(Func lookupFunction) => lookupFunction(this) ? this : null; @@ -185,5 +218,19 @@ namespace osu.Game.Rulesets.Osu.Tests MoveMouseTo(ToScreenSpace(DrawSize / 2 + DrawSize / 3 * rPos)); } } + + private partial class LegacyRotatingCursorTrail : LegacyCursorTrail + { + public LegacyRotatingCursorTrail([NotNull] ISkin skin) + : base(skin) + { + } + + protected override void Update() + { + base.Update(); + PartRotation += (float)(Time.Elapsed * 0.1); + } + } } }