From 71e2d321bebfa418a95b175c950f12acaae1b592 Mon Sep 17 00:00:00 2001 From: evill <19711659+evilldev@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:34:26 +0300 Subject: [PATCH 1/8] Fix acc challenge percentage rounding (#37155) Missed this in the earlier PR. As on [discord.](https://discord.com/channels/188630481301012481/1097318920991559880/1488164048976609420) --- osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs index 5479d512cb18..01269fdcbad6 100644 --- a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs +++ b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs @@ -43,7 +43,7 @@ public class ModAccuracyChallenge : ModFailCondition, IApplicableToScoreProcesso get { if (!MinimumAccuracy.IsDefault) - yield return ("Minimum accuracy", $"{MinimumAccuracy.Value:##%}"); + yield return ("Minimum accuracy", MinimumAccuracy.Value.ToLocalisableString(@"P1")); if (!AccuracyJudgeMode.IsDefault) yield return ("Accuracy mode", AccuracyJudgeMode.Value.ToLocalisableString()); From fc817627e56b7db1c46e2f00fc9a3bd14af51211 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 31 Mar 2026 03:45:47 +0900 Subject: [PATCH 2/8] Rework ranked play chat to reduce overall area (#37097) In discussions, we've come to the conclusion to attempt to use a chat-bubble system to minimise the effective area of the chat. In particular, the results screen doesn't give us enough space to display the full chat box without overlapping the main screen content. This PR both adds the chat to the results screen, and reworks it to use such a bubble system (not sure what to call it, IM style?). https://github.com/user-attachments/assets/a8a88c51-8a9d-4a03-92b6-621112a15a41 - New messages are previewed for 3 seconds. - When focusing and unfocusing the textbox, the history moves into expanded state (show the most recent 10 messages) or collapsed state (fade messages out ASAP). This is a bit of an initial implementation to get a feel of how it behaves, and there's more that can be done such as adding colours, improving the transforms, perhaps adding it to the intro screen (post-animation) but the structure's a bit weird atm. --------- Co-authored-by: Dean Herbert --- .../RankedPlay/TestSceneBubbleChatHistory.cs | 52 +++ .../RankedPlay/TestSceneRankedPlayChat.cs | 129 ++++++ .../Components/RankedPlayChatDisplay.cs | 401 ++++++++++++++++++ .../RankedPlay/RankedPlayScreen.cs | 19 +- .../Multiplayer/TestMultiplayerClient.cs | 3 +- osu.Game/Users/Drawables/UpdateableAvatar.cs | 4 +- 6 files changed, 594 insertions(+), 14 deletions(-) create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneBubbleChatHistory.cs create mode 100644 osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayChat.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayChatDisplay.cs diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneBubbleChatHistory.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneBubbleChatHistory.cs new file mode 100644 index 000000000000..8d06e9d75feb --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneBubbleChatHistory.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneBubbleChatHistory : OsuTestScene + { + private RankedPlayChatDisplay.BubbleChatHistory history = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = history = new RankedPlayChatDisplay.BubbleChatHistory + { + Anchor = Anchor.Centre, + Origin = Anchor.BottomCentre, + Width = 300 + }; + }); + + [Test] + public void TestPostMessages() + { + int messageId = 1; + AddRepeatStep("post message", () => history.PostMessage(new APIUser { Id = 2 }, $"message {messageId++}"), 20); + } + + [Test] + public void TestCollapse() + { + AddStep("set expanded", () => history.Expand()); + + AddStep("post some messages", () => + { + for (int i = 0; i < 10; i++) + history.PostMessage(new APIUser { Id = 2 }, $"message {i}"); + }); + + AddWaitStep("wait a bit", 10); + AddStep("set collapsed", () => history.Collapse()); + AddWaitStep("wait a bit", 10); + AddStep("set expanded", () => history.Expand()); + AddWaitStep("wait a bit", 10); + AddStep("set collapsed", () => history.Collapse()); + } + } +} diff --git a/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayChat.cs b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayChat.cs new file mode 100644 index 000000000000..839d51249997 --- /dev/null +++ b/osu.Game.Tests/Visual/RankedPlay/TestSceneRankedPlayChat.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; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.RankedPlay +{ + public partial class TestSceneRankedPlayChat : MultiplayerTestScene + { + private ChannelManager channelManager = null!; + private Channel testChannel = null!; + private int messageIdSequence; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var api = parent.Get(); + Add(channelManager = new ChannelManager(api)); + + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.Cache(channelManager); + + return dependencies; + } + + [SetUp] + public void SetUp() => Schedule(() => + { + messageIdSequence = 0; + testChannel = channelManager.JoinChannel(new Channel { Id = 1, Type = ChannelType.Multiplayer }); + }); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => + { + var room = CreateDefaultRoom(MatchType.RankedPlay); + room.ChannelId = 1; + JoinRoom(room); + }); + + WaitForJoined(); + + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 })); + + AddStep("load screen", () => LoadScreen(new RankedPlayScreen(MultiplayerClient.ClientRoom!))); + } + + [Test] + public void TestDiscardCardStage() + { + AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely()); + + postLocalUserMessage("this is a message from the local user"); + postOpponentMessage("this is a message from the opponent"); + } + + [Test] + public void TestResultsStage() + { + AddStep("set results state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.Results, state => + { + int losingPlayer = state.Users.Keys.First(); + + foreach (var (id, userInfo) in state.Users) + { + if (id == losingPlayer) + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 123_456, + Damage = 123_456, + OldLife = 500_000, + NewLife = 500_000 - 123_456, + }; + + userInfo.Life = 500_000 - 123_456; + } + else + { + userInfo.DamageInfo = new RankedPlayDamageInfo + { + RawDamage = 0, + Damage = 0, + OldLife = 1_000_000, + NewLife = 1_000_000, + }; + } + } + }).WaitSafely()); + } + + private void postLocalUserMessage(string content) + { + AddStep("add local user message", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Timestamp = DateTimeOffset.Now, + Sender = API.LocalUser.Value, + Content = content + })); + } + + private void postOpponentMessage(string content) + { + AddStep("add opponent message", () => testChannel.AddNewMessages(new Message(messageIdSequence++) + { + Timestamp = DateTimeOffset.Now, + Sender = new APIUser + { + Id = 2, + Username = "peppy" + }, + Content = content + })); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayChatDisplay.cs new file mode 100644 index 000000000000..94cef92fc896 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/Components/RankedPlayChatDisplay.cs @@ -0,0 +1,401 @@ +// 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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; +using osu.Game.Input; +using osu.Game.Input.Bindings; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Online.Multiplayer; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Users.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Components +{ + public partial class RankedPlayChatDisplay : CompositeDrawable, IKeyBindingHandler + { + [Resolved] + private ChannelManager? channelManager { get; set; } + + [Resolved] + private RealmKeyBindingStore keyBindingStore { get; set; } = null!; + + private readonly MultiplayerRoom room; + + private ChatTextBox textbox = null!; + private BubbleChatHistory chatHistory = null!; + + private Channel? channel; + + private const float width = 320; + + public RankedPlayChatDisplay(MultiplayerRoom room) + { + Size = new Vector2(width, 160); + this.room = room; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + textbox = new ChatTextBox + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + RelativeSizeAxes = Axes.X, + Height = 30, + CornerRadius = 10, + ReleaseFocusOnCommit = true, + HoldFocus = false, + Focus = onFocusGained, + FocusLost = onFocusLost + }, + new Container + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Bottom = 35 }, + Child = chatHistory = new BubbleChatHistory + { + RelativeSizeAxes = Axes.X + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + resetPlaceholderText(); + textbox.OnCommit += onCommit; + + channel = channelManager?.JoinChannel(new Channel { Id = room.ChannelID, Type = ChannelType.Multiplayer, Name = $"#lazermp_{room.RoomID}" }); + if (channel != null) + channel.NewMessagesArrived += onNewMessagesArrived; + } + + private void onCommit(TextBox sender, bool newText) + { + string text = textbox.Text.Trim(); + + if (string.IsNullOrWhiteSpace(text)) + return; + + if (text[0] == '/') + channelManager?.PostCommand(text[1..], channel); + else + channelManager?.PostMessage(text, target: channel); + + textbox.Text = string.Empty; + } + + private void onNewMessagesArrived(IEnumerable bundle) + { + foreach (var message in bundle) + chatHistory.PostMessage(message.Sender, message.Content); + } + + private void onFocusGained() + { + textbox.PlaceholderText = ChatStrings.InputPlaceholder; + chatHistory.Expand(); + } + + private void onFocusLost() + { + resetPlaceholderText(); + chatHistory.Collapse(); + } + + private void resetPlaceholderText() + { + textbox.PlaceholderText = Localisation.ChatStrings.InGameInputPlaceholder(keyBindingStore.GetBindingsStringFor(GlobalAction.ToggleChatFocus)); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.Back: + if (textbox.HasFocus) + { + Schedule(() => textbox.KillFocus()); + return true; + } + + break; + + case GlobalAction.ToggleChatFocus: + if (!textbox.HasFocus) + { + Schedule(() => textbox.TakeFocus()); + return true; + } + + break; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + public void Appear() + { + FinishTransforms(); + + this.MoveToY(150f) + .FadeOut() + .MoveToY(0f, 240, Easing.OutCubic) + .FadeIn(240, Easing.OutCubic); + } + + public TransformSequence Disappear() + { + FinishTransforms(); + + return this.FadeOut(240, Easing.InOutCubic) + .MoveToY(150f, 240, Easing.InOutCubic); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (channel != null) + channel.NewMessagesArrived -= onNewMessagesArrived; + } + + private partial class ChatTextBox : StandAloneChatDisplay.ChatTextBox + { + protected override void LoadComplete() + { + base.LoadComplete(); + + BackgroundFocused = Colour4.FromHex("222228"); + BackgroundUnfocused = BackgroundFocused.Opacity(0.7f); + Placeholder.Colour = Color4.White; + } + } + + public partial class BubbleChatHistory : CompositeDrawable + { + /// + /// Maximum number of recent messages to keep. + /// + private const int max_length = 10; + + /// + /// The vertical spacing between messages. + /// + private const float message_spacing = 2; + + /// + /// When in a collapsed state, the time before a newly-posted message disappears from view. + /// + private const float time_before_disappear = 5000; + + private readonly Container messageContainer; + + private bool expanded; + + public BubbleChatHistory() + { + AutoSizeAxes = Axes.Y; + + InternalChild = messageContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }; + } + + /// + /// Collapses the display such that only new messages are temporarily shown. + /// + public void Collapse() + { + expanded = false; + + foreach (var child in messageContainer.Reverse().Take(max_length).Reverse()) + { + // Normally we wait an amount of time to preview messages before they disappear. + // When quickly toggling expanded and collapsed states, we want to still consider this preview window. + double previewTimeRemaining = Math.Max(0, time_before_disappear - (Time.Current - child.PostTime)); + + using (BeginDelayedSequence(previewTimeRemaining)) + child.Hide(); + } + } + + /// + /// Expands the display such that all historical messages are shown. + /// + public void Expand() + { + expanded = true; + + foreach (var child in messageContainer.Reverse().Take(max_length)) + child.Show(); + } + + /// + /// Posts a message. + /// + /// The user that posted the message. + /// The message content. + public void PostMessage(APIUser user, string content) + { + var newMessage = new MessageBubble(user, content) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + PostTime = Time.Current + }; + + messageContainer.Add(newMessage); + + float offset = 0; + + ScheduleAfterChildren(() => + { + // Layout bubbles, pushing all others upwards to make room for the new one. + foreach (var child in messageContainer.Reverse()) + { + child.MoveToY(-offset, 400, Easing.OutPow10); + offset += child.DrawHeight + message_spacing; + } + }); + + // Hide any overflowing message. + // Only need to handle the most-recently-overflowing one, because others would be handled in prior calls to this method. + if (messageContainer.Count > max_length) + { + var lastBubble = messageContainer[messageContainer.Count - max_length - 1]; + + lastBubble.Hide(); + lastBubble.Expire(); + } + + newMessage.Show(); + + // If not in the expanded state, hide the new message after a short while. + if (!expanded) + { + using (BeginDelayedSequence(time_before_disappear)) + newMessage.Hide(); + } + } + + private partial class MessageBubble : CompositeDrawable + { + private readonly APIUser user; + private readonly string message; + + /// + /// The time at which this message was posted. + /// + public required double PostTime { get; init; } + + public MessageBubble(APIUser user, string message) + { + this.user = user; + this.message = message; + AutoSizeAxes = Axes.Both; + + Scale = Vector2.Zero; + Alpha = 0; + } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.Both, + CornerRadius = 8, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = api.LocalUser.Value.Id == user.Id + ? RankedPlayColourScheme.Blue.PrimaryDarkest + : RankedPlayColourScheme.Red.PrimaryDarkest, + }, + new Container + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(8), + Children = new Drawable[] + { + new CircularContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(16), + Masking = true, + Child = new UpdateableAvatar(user) + { + DelayedLoad = false, + RelativeSizeAxes = Axes.Both + } + }, + new OsuTextFlowContainer + { + X = 20, + MaximumSize = new Vector2(width * 1.5f, 0), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Text = message, + } + } + } + } + } + }; + } + + public override void Show() + { + this.ScaleTo(1, 400, Easing.OutElasticQuarter) + .FadeIn(200, Easing.OutQuint); + } + + public override void Hide() + { + this.FadeOut(200, Easing.OutQuint); + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs index ee4556e1b824..d8571b9db5b2 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/RankedPlayScreen.cs @@ -24,7 +24,6 @@ using osu.Game.Overlays.Volume; using osu.Game.Rulesets; using osu.Game.Screens.OnlinePlay.Components; -using osu.Game.Screens.OnlinePlay.Matchmaking.Match; using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Gameplay; using osu.Game.Screens.OnlinePlay.Matchmaking.Queue; using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card; @@ -71,7 +70,7 @@ public partial class RankedPlayScreen : OsuScreen, IPreviewTrackOwner, IHandlePr private readonly MultiplayerRoom room; private readonly Container screenContainer; - private readonly MatchmakingChatDisplay chat; + private readonly RankedPlayChatDisplay chat; private IBindable stage = null!; @@ -110,11 +109,10 @@ public RankedPlayScreen(MultiplayerRoom room) { RelativeSizeAxes = Axes.Both, }, - chat = new MatchmakingChatDisplay(new Room(room)) + chat = new RankedPlayChatDisplay(room) { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Size = new Vector2(320, 160), Margin = new MarginPadding { Bottom = 10, @@ -173,14 +171,6 @@ protected override void LoadComplete() }, ]); - cornerPieceVisibility.BindValueChanged(e => - { - if (e.NewValue == Visibility.Visible) - chat.Appear(); - else - chat.Disappear(); - }); - stage.BindValueChanged(e => onStageChanged(e.NewValue)); } @@ -232,9 +222,12 @@ private void onLoadRequested() => Scheduler.Add(() => private void onStageChanged(RankedPlayStage stage) { + chat.Appear(); + switch (stage) { case RankedPlayStage.RoundWarmup when matchInfo.CurrentRound == 1: + chat.Disappear(); ShowScreen(new IntroScreen()); break; @@ -284,6 +277,7 @@ private void onStageChanged(RankedPlayStage stage) public override void OnSuspending(ScreenTransitionEvent e) { + chat.Disappear(); previewTrackManager.StopAnyPlaying(this); base.OnSuspending(e); @@ -331,6 +325,7 @@ public override void OnResuming(ScreenTransitionEvent e) { base.OnResuming(e); + chat.Appear(); if (e.Last is not MultiplayerPlayerLoader playerLoader) return; diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 64ebaab783a2..eed9d41f772f 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -260,7 +260,8 @@ protected override async Task JoinRoomInternal(long roomId, str }, Playlist = ServerAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)).ToList(), Users = { localUser }, - Host = localUser + Host = localUser, + ChannelID = ServerAPIRoom.ChannelId }; await changeMatchType(ServerRoom.Settings.MatchType).ConfigureAwait(false); diff --git a/osu.Game/Users/Drawables/UpdateableAvatar.cs b/osu.Game/Users/Drawables/UpdateableAvatar.cs index 21153ecfc339..5210e8235aca 100644 --- a/osu.Game/Users/Drawables/UpdateableAvatar.cs +++ b/osu.Game/Users/Drawables/UpdateableAvatar.cs @@ -43,7 +43,9 @@ public APIUser? User set => base.EdgeEffect = value; } - protected override double LoadDelay => 200; + public bool DelayedLoad = true; + + protected override double LoadDelay => DelayedLoad ? 200 : 0; private readonly bool isInteractive; private readonly bool showGuestOnNull; From 9f665cbf115d6bc336b5ed60697118a4d601a239 Mon Sep 17 00:00:00 2001 From: Austin Moore Date: Tue, 31 Mar 2026 07:42:10 +0000 Subject: [PATCH 3/8] Replace MacOS hitsound composer "Alt" text tooltip with "Opt". (#37156) - Closes https://github.com/ppy/osu/issues/37055 In the editor, keybinds and the tooltip for the hitsounding section are hardcoded. Since the keybind contains "Alt", this is inconsistent on MacOS where "Opt" is used instead. Before: image After (MacOS only): image --------- Co-authored-by: Dean Herbert --- osu.Game/Rulesets/Edit/HitObjectComposer.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index c138808890ef..84b786c45a8d 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Testing; @@ -106,7 +107,7 @@ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnl dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); [BackgroundDependencyLoader(true)] - private void load(OsuConfigManager config, [CanBeNull] Editor editor) + private void load(OsuConfigManager config, [CanBeNull] Editor editor, ReadableKeyCombinationProvider keyCombinationProvider) { autoSeekOnPlacement = config.GetBindable(OsuSetting.EditorAutoSeekOnPlacement); @@ -135,6 +136,9 @@ private void load(OsuConfigManager config, [CanBeNull] Editor editor) dependencies.CacheAs(Playfield); + string shiftDisplay = keyCombinationProvider.GetReadableString(new KeyCombination(InputKey.Shift)); + string altDisplay = keyCombinationProvider.GetReadableString(new KeyCombination(InputKey.Alt)); + InternalChildren = new[] { PlayfieldContentContainer = new Container @@ -180,7 +184,7 @@ private void load(OsuConfigManager config, [CanBeNull] Editor editor) Spacing = new Vector2(0, 5), }, }, - new EditorToolboxGroup("bank (Shift/Alt-Q~R)") + new EditorToolboxGroup($"bank ({shiftDisplay}/{altDisplay}-Q~R)") { Child = new FillFlowContainer { From 3edc428c3a084896ec6fcb4f09528cdd0668c0ef Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 31 Mar 2026 19:42:42 +0900 Subject: [PATCH 4/8] Update resources --- osu.Game/Overlays/AfToggleSection.cs | 473 +++++++++++++++++++++++++++ osu.Game/Overlays/SettingsOverlay.cs | 9 +- osu.Game/osu.Game.csproj | 2 +- 3 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 osu.Game/Overlays/AfToggleSection.cs diff --git a/osu.Game/Overlays/AfToggleSection.cs b/osu.Game/Overlays/AfToggleSection.cs new file mode 100644 index 000000000000..b9a8d8fcf9aa --- /dev/null +++ b/osu.Game/Overlays/AfToggleSection.cs @@ -0,0 +1,473 @@ +// 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.Linq; +using ManagedBass.Fx; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Audio.Effects; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; +using osu.Game.Overlays.Settings; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays +{ + public partial class AfToggleSection : SettingsSection + { + public override LocalisableString Header => "Toggles"; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Regular.Dizzy, + }; + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new ToggleSettings(), + }; + } + + public partial class ToggleSettings : SettingsSubsection + { + [Resolved] + private OsuGameBase game { get; set; } = null!; + + [Resolved] + private GameHost host { get; set; } = null!; + + [Resolved] + private MusicController musicController { get; set; } = null!; + + protected override LocalisableString Header => "You wanted toggles, we give you toggles"; + + private List words = new List(); + + private List>> heroActions = new List>>(); + + private readonly List tasks = new List(); + + private GlobalActionContainer target = null!; + + private AutoWahParameters autoWahParameters = null!; + private ChorusParameters chorusParameters = null!; + private PhaserParameters phaserParameters = null!; + private Bindable balanceAdjustment = null!; + + private Sprite? dvdLogo; + private Vector2 dvdLogoVelocity = new Vector2(0.005f); + + private ScheduledDelegate? dvdLogoMovement; + + private AudioManager audio = null!; + + private double lastUpdate; + + [Resolved] + private TextureStore textures { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + autoWahParameters = new AutoWahParameters { fDryMix = 0.5f, fWetMix = 0.5f }; + chorusParameters = new ChorusParameters { fDryMix = 0.5f, fWetMix = 0.5f }; + phaserParameters = new PhaserParameters { fDryMix = 0.5f, fWetMix = 0.5f }; + balanceAdjustment = new Bindable(1); + + audio = game.Audio; + + target = game.ChildrenOfType().First(); + target.Anchor = Anchor.Centre; + target.Origin = Anchor.Centre; + + reset(); + } + + protected override void Update() + { + base.Update(); + + lastUpdate = Clock.CurrentTime; + + tasks.RemoveAll(t => t.Cancelled || t.Completed); + + if (tasks.Count > 40) + explode(); + } + + private void explode() + { + foreach (var t in tasks) + t.Cancel(); + + target.FadeColour(Color4.Black, 2000); + target.ScaleTo(1) + .ScaleTo(10, 4000); + + audio.Samples.Get("Gameplay/Argon/failsound-alt")?.Play(); + musicController.DuckMomentarily(2000, new DuckParameters + { + DuckDuration = 0, + DuckVolumeTo = 0, + DuckCutoffTo = AudioFilter.MAX_LOWPASS_CUTOFF, + RestoreDuration = 3000, + RestoreEasing = Easing.InQuint, + }); + + host.UpdateThread.Scheduler.AddDelayed(() => + { + foreach (var c in getCheckboxes()) + { + c.Current.Disabled = false; + c.Current.Value = false; + } + + target.FinishTransforms(); + target.ScaleTo(1) + .FadeColour(Color4.White, 2000, Easing.OutQuint); + + Schedule(() => + { + var settingsOverlay = game.ChildrenOfType().First(); + + settingsOverlay.SectionsContainer.ScrollTo(settingsOverlay.ChildrenOfType().Single()); + }); + + Clear(); + + AddRange(new Drawable[] + { + new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.TopCentre, + Text = "..on second thought, no more toggles for you." + }, + new SettingsButtonV2 + { + Text = "But please peppy", + Action = () => + { + if (Children.Count < 5 || RNG.NextSingle() > 0.08f) + { + audio.Samples.Get("Gameplay/sectionfail")?.Play(); + Add(new OsuTextFlowContainer(p => p.Font = OsuFont.Default.With(size: RNG.Next(10, 30))) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.TopCentre, + Text = "no" + }); + } + else + { + audio.Samples.Get("Gameplay/sectionpass")?.Play(); + reset(); + } + } + } + }); + }, 3000); + } + + private void reset() + { + Clear(); + + words = new List( + [ + "toggle", "toggleability", "inversion", "momentum", "uncertainty", "viscosity", "gravity", "shyness", "peer pressure", "amnesia", "rebellion", "telepathy", "commitment issues", + "democracy", "entropy", "recursion", "quantum superposition", "paperclips", "stubbornness", "existentialism", "nostalgia", "anxiety", "procrastination", "synesthesia", + "narcissism", + "chaos", + "determinism", "nihilism", "optimism", "pessimism", "apathy", "enthusiasm", "seven twenty seven", "lethargy", "hyperactivity", "confusion", "clarity", "ambiguity", "precision", + "vagueness", + "specificity", "generality", "locality", "annihilation", "nonlocality", "causality", "acausality", "reversibility", "irreversibility", "coherence", "decoherence", "entanglement", + "disentanglement", "superposition" + ]); + + heroActions = new List>> + { + val => target.FadeColour(OsuColour.Gray(val.NewValue ? 0.5f : 1), 2500, Easing.OutPow10), + val => + { + if (val.NewValue) + { + target.ScaleTo(new Vector2(RNG.NextBool() ? -1 : 1, RNG.NextBool() ? -1 : 1)) + .Delay(1500) + .ScaleTo(Vector2.One); + } + }, + val => + { + if (val.NewValue) + { + target.FadeColour(new Color4( + (byte)RNG.Next(200, 255), + (byte)RNG.Next(200, 255), + (byte)RNG.Next(200, 255), + 255), 1500, Easing.OutQuint); + } + else + target.FadeColour(Color4.White, 2000); + }, + val => + { + if (val.NewValue) + target.RotateTo(RNG.NextSingle(-2f, 2f), 4000, Easing.InOutSine); + else + target.RotateTo(0, 4000, Easing.InOutSine); + }, + val => target.ScaleTo(val.NewValue ? 1.05f : 1f, 3000, Easing.OutQuint), + val => + { + if (val.NewValue) + target.ScaleTo(1.02f, 2000, Easing.InOutSine).Then().ScaleTo(0.98f, 2000, Easing.InOutSine).Loop(); + else + target.ScaleTo(1f, 1000); + }, + val => + { + if (val.NewValue) + { + var colours = new[] { new Color4(255, 230, 230, 255), new Color4(230, 255, 230, 255), new Color4(230, 230, 255, 255) }; + target.FadeColour(colours[RNG.Next(0, colours.Length)], 300).Loop(); + } + else + target.FadeColour(Color4.White, 3000); + }, + val => + { + if (val.NewValue) + target.MoveToX(10, 500, Easing.OutQuad).Then().MoveToY(0, 500, Easing.InQuad); + }, + val => + { + if (val.NewValue) + target.MoveToY(-10, 500, Easing.OutQuad).Then().MoveToY(0, 500, Easing.InQuad); + }, + _ => game.ChildrenOfType().FirstOrDefault()?.ToggleVisibility(), + val => + { + if (val.NewValue) + audio.TrackMixer.AddEffect(chorusParameters); + else + audio.TrackMixer.RemoveEffect(chorusParameters); + }, + val => + { + if (val.NewValue) + audio.TrackMixer.AddEffect(autoWahParameters); + else + audio.TrackMixer.RemoveEffect(autoWahParameters); + }, + val => + { + if (val.NewValue) + audio.TrackMixer.AddEffect(phaserParameters); + else + audio.TrackMixer.RemoveEffect(phaserParameters); + }, + + // game blinks at you + val => + { + if (val.NewValue) + { + target.ScaleTo(new Vector2(1, 0), 50, Easing.OutQuint) + .Then().ScaleTo(Vector2.One, 50, Easing.OutQuint); + } + }, + + // messing with balance + val => + { + if (val.NewValue) + { + audio.AddAdjustment(AdjustableProperty.Balance, balanceAdjustment); + this.TransformBindableTo(balanceAdjustment, 0.3f, 5_000) + .Then().TransformBindableTo(balanceAdjustment, 0.7f, 10_000) + .Then().TransformBindableTo(balanceAdjustment, 0.5f, 5_000) + .Loop(); + } + else + { + audio.RemoveAdjustment(AdjustableProperty.Balance, balanceAdjustment); + } + }, + + // do a barrel roll! (press Z or R twice) + val => + { + if (val.NewValue) + { + target.RotateTo(360, 1000) + .Then().RotateTo(0); + } + }, + val => + { + if (val.NewValue) + { + if (dvdLogo == null) + { + target.Add(dvdLogo = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Depth = float.MinValue, + Size = new Vector2(30), + Texture = textures.Get(@"Menu/logo"), + RelativePositionAxes = Axes.Both, + Position = new Vector2(RNG.NextSingle(), RNG.NextSingle()), + Alpha = 0, + }); + } + + dvdLogo.Alpha = 1; + dvdLogoMovement = host.UpdateThread.Scheduler.AddDelayed(() => + { + var logoPosition = dvdLogo.ScreenSpaceDrawQuad; + var bounds = target.ScreenSpaceDrawQuad; + + if (logoPosition.TopLeft.X < bounds.TopLeft.X) + dvdLogoVelocity = new Vector2(0.005f, dvdLogoVelocity.Y); + else if (logoPosition.BottomRight.X > bounds.BottomRight.X) + dvdLogoVelocity = new Vector2(-0.005f, dvdLogoVelocity.Y); + + if (logoPosition.TopLeft.Y < bounds.TopLeft.Y) + dvdLogoVelocity = new Vector2(dvdLogoVelocity.X, 0.005f); + else if (logoPosition.BottomRight.Y > bounds.BottomRight.Y) + dvdLogoVelocity = new Vector2(dvdLogoVelocity.X, -0.005f); + + dvdLogo.Position += dvdLogoVelocity; + }, 15, true); + } + else + { + dvdLogo?.Hide(); + dvdLogoMovement?.Cancel(); + } + }, + val => + { + if (val.NewValue) + { + string[] samples = + [ + "Keyboard/key-caps.mp3", + "Keyboard/key-confirm.mp3", + "Keyboard/key-delete.mp3", + "Keyboard/key-movement.mp3", + "Keyboard/key-press-1.mp3", + "Keyboard/key-press-2.mp3", + "Keyboard/key-press-3.mp3", + "Keyboard/key-press-4.mp3", + "Keyboard/deselect.wav", + "Keyboard/key-invalid.wav", + "Keyboard/select-all.wav", + "Keyboard/select-char.wav", + "Keyboard/select-word.wav" + ]; + + var repeat = Scheduler.AddDelayed(() => + { + audio.Samples.Get(samples[RNG.Next(0, samples.Length)])?.Play(); + }, 100, true); + + Scheduler.AddDelayed(repeat.Cancel, 1000); + } + } + }; + + addNewToggle(); + } + + private void doRandomThing() + { + // avoid runaway if settings panel closes + if (Math.Abs(Clock.CurrentTime - lastUpdate) > 100) + return; + + tasks.Add(host.UpdateThread.Scheduler.AddDelayed(() => + { + var formCheckBoxes = getCheckboxes(); + + if (heroActions.Count > 0 && RNG.NextSingle() > 0.5f) + { + addNewToggle(); + return; + } + + foreach (var c in formCheckBoxes.Take(Math.Max(1, formCheckBoxes.Length / 8))) + { + if (RNG.NextSingle() > 0.5f && formCheckBoxes.Count(b => !b.Current.Disabled) > 5) + c.Current.Disabled = !c.Current.Disabled; + + if (c.Current.Disabled && RNG.NextSingle() > 0.4f) + c.Current.Disabled = false; + + if (RNG.NextSingle() > 0.3f) + c.TriggerClick(); + } + }, RNG.Next(400, 2000))); + } + + private FormCheckBox[] getCheckboxes() => + Children.OfType().Select(s => s.Control) + .OfType() + .OrderBy(_ => RNG.Next(-1, 1)) + .ToArray(); + + private void addNewToggle() + { + if (words.Count == 0) + return; + + FormCheckBox checkbox; + + string word = words[RNG.Next(0, words.Count)]; + words.Remove(word); + + Add(new SettingsItemV2(checkbox = new FormCheckBox + { + Caption = $"Toggle {word}" + }) + { + Depth = RNG.NextSingle(), + }); + + checkbox.Current.BindValueChanged(val => + { + if (val.NewValue) + doRandomThing(); + }); + + if (RNG.NextSingle() > 0.6f) + { + int i = RNG.Next(0, heroActions.Count); + checkbox.Current.BindValueChanged(heroActions[i]); + heroActions.RemoveAt(i); + } + } + } + } +} diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index 3065a4d1bddf..e625afe94122 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -30,7 +31,7 @@ public partial class SettingsOverlay : SettingsPanel, INamedOverlayComponent protected override IEnumerable CreateSections() { - return new List + var sections = new List { // This list should be kept in sync with ScreenBehaviour. new GeneralSection(), @@ -45,6 +46,12 @@ protected override IEnumerable CreateSections() new MaintenanceSection(), new DebugSection() }; + + var today = DateTimeOffset.Now; + if (today.Month == 4 && today.Day == 1) + sections.Insert(9, new AfToggleSection()); + + return sections; } private readonly List subPanels = new List(); diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 4ca602fcf9fb..8850de3a7cf4 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -40,7 +40,7 @@ - + From cbcbc788b89373358867192231d66a0bc63ca1f9 Mon Sep 17 00:00:00 2001 From: Austin Moore Date: Tue, 31 Mar 2026 14:33:53 +0000 Subject: [PATCH 5/8] "Fix" performance drop caused by changing scroll speed (#37149) The fix is just disabling the animation. It works I guess. --- - Closes https://github.com/ppy/osu/issues/37042 Currently in Mania, you can change the scroll speed for a brief period during the beginning of a song. However this scroll speed change occurs over a short period of time, which causes a bunch of extra hit object updates, causing major fps and latency drops. This fix simply replaces the dampening with an immediate scroll speed update. Since the scroll speed can only be updated for a short time at the beginning of the song, providing immediate visual feedback on the scroll speed makes sense to me. However another potential solution would be to filter the TimeRange Value updates to keep the gradual scroll speed visual change, while greatly reducing the number of updates to the hit objects currently on screen. If there is any feedback I would greatly appreciate it as this is my first issue here. I had ran both inspectCode.ps1 and the code formatter before creating the merge request. Thank you. Before fix: https://github.com/user-attachments/assets/55e30894-7341-414a-af2e-2ec051c3a252 After fix: https://github.com/user-attachments/assets/c085d33f-c0ae-45dd-8131-e79a5682b9ca --------- Co-authored-by: Dean Herbert --- .../Edit/DrawableManiaEditorRuleset.cs | 2 +- .../UI/DrawableManiaRuleset.cs | 38 +------------------ .../Navigation/TestSceneScreenNavigation.cs | 2 +- 3 files changed, 4 insertions(+), 38 deletions(-) diff --git a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs index 181bc7341c20..49ee83dc177f 100644 --- a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs +++ b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs @@ -44,7 +44,7 @@ protected override void LoadComplete() protected override void Update() { - TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed)) : TimelineTimeRange.Value; + TimeRange.Value = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed)) : TimelineTimeRange.Value; base.Update(); } } diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index d9a03d1c3099..65574aa4f36f 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -9,9 +9,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Input; -using osu.Framework.Platform; using osu.Framework.Threading; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Input.Handlers; @@ -20,7 +18,6 @@ using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Replays; -using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -61,18 +58,11 @@ public partial class DrawableManiaRuleset : DrawableScrollingRuleset mobileLayout = new Bindable(); private readonly Bindable touchOverlay = new Bindable(); - public double TargetTimeRange { get; protected set; } - - private double currentTimeRange; - // Stores the current speed adjustment active in gameplay. private readonly Track speedAdjustmentTrack = new TrackVirtual(0); private ISkinSource currentSkin = null!; - [Resolved] - private GameHost gameHost { get; set; } = null!; - public DrawableManiaRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods = null) : base(ruleset, beatmap, mods) { @@ -116,10 +106,10 @@ private void load(ISkinSource source) if (!AllowScrollSpeedAdjustment) return; - TargetTimeRange = ComputeScrollTime(speed.NewValue); + TimeRange.Value = ComputeScrollTime(speed.NewValue); }); - TimeRange.Value = TargetTimeRange = currentTimeRange = ComputeScrollTime(configScrollSpeed.Value); + TimeRange.Value = ComputeScrollTime(configScrollSpeed.Value); Config.BindWith(ManiaRulesetSetting.MobileLayout, mobileLayout); mobileLayout.BindValueChanged(_ => updateMobileLayout(), true); @@ -145,14 +135,7 @@ private void updateMobileLayout() protected override void AdjustScrollSpeed(int amount) => configScrollSpeed.Value += amount; - protected override void Update() - { - base.Update(); - updateTimeRange(); - } - private ScheduledDelegate? pendingSkinChange; - private float hitPosition; private void onSkinChange() { @@ -164,26 +147,9 @@ private void onSkinChange() private void skinChanged() { - hitPosition = currentSkin.GetConfig( - new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.HitPosition))?.Value - ?? Stage.HIT_TARGET_POSITION; - pendingSkinChange = null; } - private void updateTimeRange() - { - const float length_to_default_hit_position = 768 - LegacyManiaSkinConfiguration.DEFAULT_HIT_POSITION; - float lengthToHitPosition = 768 - hitPosition; - - // This scaling factor preserves the scroll speed as the scroll length varies from changes to the hit position. - float scale = lengthToHitPosition / length_to_default_hit_position; - - // we're intentionally using the game host's update clock here to decouple the time range tween from the gameplay clock (which can be arbitrarily paused, or even rewinding) - currentTimeRange = Interpolation.DampContinuously(currentTimeRange, TargetTimeRange, 50, gameHost.UpdateThread.Clock.ElapsedFrameTime); - TimeRange.Value = currentTimeRange * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value * scale; - } - /// /// Computes a scroll time (in milliseconds) from a scroll speed in the range of 1-40. /// diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 68aaba6c68f0..f24767bd5422 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -472,7 +472,7 @@ public void TestScrollSpeedAdjustDuringGameplay() void checkScrollSpeed(double configValue, double gameplayValue) { AddUntilStep($"config value is {configValue}", () => getConfigManager().Get(ManiaRulesetSetting.ScrollSpeed), () => Is.EqualTo(configValue)); - AddUntilStep($"gameplay value is {gameplayValue}", () => this.ChildrenOfType().Single().TargetTimeRange, + AddUntilStep($"gameplay value is {gameplayValue}", () => this.ChildrenOfType().Single().ScrollingInfo.TimeRange.Value, () => Is.EqualTo(DrawableManiaRuleset.ComputeScrollTime(gameplayValue))); } From b3c15339d040e0085910b6d88c09d148b0e5d8a6 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 1 Apr 2026 01:03:00 +0900 Subject: [PATCH 6/8] Fix regression in mania scroll speed calculation logic --- .../UI/DrawableManiaRuleset.cs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index 65574aa4f36f..c73e37e39c63 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -18,6 +18,7 @@ using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Replays; +using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -58,6 +59,8 @@ public partial class DrawableManiaRuleset : DrawableScrollingRuleset mobileLayout = new Bindable(); private readonly Bindable touchOverlay = new Bindable(); + public double TargetTimeRange { get; protected set; } + // Stores the current speed adjustment active in gameplay. private readonly Track speedAdjustmentTrack = new TrackVirtual(0); @@ -106,10 +109,10 @@ private void load(ISkinSource source) if (!AllowScrollSpeedAdjustment) return; - TimeRange.Value = ComputeScrollTime(speed.NewValue); + TargetTimeRange = ComputeScrollTime(speed.NewValue); }); - TimeRange.Value = ComputeScrollTime(configScrollSpeed.Value); + TimeRange.Value = TargetTimeRange = ComputeScrollTime(configScrollSpeed.Value); Config.BindWith(ManiaRulesetSetting.MobileLayout, mobileLayout); mobileLayout.BindValueChanged(_ => updateMobileLayout(), true); @@ -135,7 +138,14 @@ private void updateMobileLayout() protected override void AdjustScrollSpeed(int amount) => configScrollSpeed.Value += amount; + protected override void Update() + { + base.Update(); + updateTimeRange(); + } + private ScheduledDelegate? pendingSkinChange; + private float hitPosition; private void onSkinChange() { @@ -147,9 +157,24 @@ private void onSkinChange() private void skinChanged() { + hitPosition = currentSkin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.HitPosition))?.Value + ?? Stage.HIT_TARGET_POSITION; + pendingSkinChange = null; } + private void updateTimeRange() + { + const float length_to_default_hit_position = 768 - LegacyManiaSkinConfiguration.DEFAULT_HIT_POSITION; + float lengthToHitPosition = 768 - hitPosition; + + // This scaling factor preserves the scroll speed as the scroll length varies from changes to the hit position. + float scale = lengthToHitPosition / length_to_default_hit_position; + + TimeRange.Value = TargetTimeRange * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value * scale; + } + /// /// Computes a scroll time (in milliseconds) from a scroll speed in the range of 1-40. /// From bb63a17c309e51c49759ad0bc688ccbfab790d38 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 1 Apr 2026 01:14:02 +0900 Subject: [PATCH 7/8] Fix minor oversight in transform logic --- osu.Game/Overlays/AfToggleSection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Overlays/AfToggleSection.cs b/osu.Game/Overlays/AfToggleSection.cs index b9a8d8fcf9aa..b9aaa0f64e97 100644 --- a/osu.Game/Overlays/AfToggleSection.cs +++ b/osu.Game/Overlays/AfToggleSection.cs @@ -257,7 +257,7 @@ private void reset() val => { if (val.NewValue) - target.MoveToX(10, 500, Easing.OutQuad).Then().MoveToY(0, 500, Easing.InQuad); + target.MoveToX(10, 500, Easing.OutQuad).Then().MoveToX(0, 500, Easing.InQuad); }, val => { From 5c20254f76f407c8b852896fd1dd4585511ba358 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 1 Apr 2026 02:03:11 +0900 Subject: [PATCH 8/8] Fix editor not showing beatmap background in some cases --- .../BeatmapBackgroundWithStoryboard.cs | 20 ++++++++++++---- .../Backgrounds/EditorBackgroundScreen.cs | 24 ++++--------------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs index 92b5b956b4fb..cfc034087a2b 100644 --- a/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs +++ b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs @@ -29,6 +29,8 @@ public partial class BeatmapBackgroundWithStoryboard : BeatmapBackground public Action? StoryboardLoaded { get; set; } + public readonly BindableBool ShowStoryboard = new BindableBool(true); + [Resolved(CanBeNull = true)] private MusicController? musicController { get; set; } @@ -75,13 +77,11 @@ public void LoadStoryboard(bool async = true) void finishLoad(DrawableStoryboard s) { - if (Beatmap.Storyboard.ReplacesBackground) - Sprite.FadeOut(BackgroundScreen.TRANSITION_LENGTH, Easing.InQuint); - Storyboard.FadeInFromZero(BackgroundScreen.TRANSITION_LENGTH, Easing.OutQuint); Storyboard.Add(s); StoryboardLoaded?.Invoke(); + updateStoryboardVisibility(); } } @@ -97,8 +97,7 @@ public void UnloadStoryboard() Storyboard.Clear(); drawableStoryboard = null; - - Sprite.Alpha = 1f; + updateStoryboardVisibility(); } protected override void LoadComplete() @@ -108,6 +107,17 @@ protected override void LoadComplete() musicController.TrackChanged += onTrackChanged; updateStoryboardClockSource(Beatmap); + + ShowStoryboard.BindValueChanged(_ => updateStoryboardVisibility(), true); + } + + private void updateStoryboardVisibility() + { + bool showStoryboard = drawableStoryboard != null && ShowStoryboard.Value; + bool showBackground = !showStoryboard || !Beatmap.Storyboard.ReplacesBackground; + + Storyboard.FadeTo(showStoryboard ? 1 : 0, BackgroundScreen.TRANSITION_LENGTH, Easing.OutQuint); + Sprite.FadeTo(showBackground ? 1 : 0, BackgroundScreen.TRANSITION_LENGTH, Easing.OutQuint); } private void onTrackChanged(WorkingBeatmap newBeatmap, TrackChangeDirection _) => updateStoryboardClockSource(newBeatmap); diff --git a/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs index f44cbbea819d..e0b71151c60c 100644 --- a/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs +++ b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs @@ -25,7 +25,7 @@ public partial class EditorBackgroundScreen : BackgroundScreen private Bindable dimLevel = null!; private Bindable showStoryboard = null!; - private BeatmapBackgroundWithStoryboard? background; + private BeatmapBackgroundWithStoryboard background = null!; private readonly Container content; private readonly Box blackBox; @@ -65,7 +65,6 @@ private void load(OsuConfigManager config) showStoryboard = config.GetBindable(OsuSetting.EditorShowStoryboard); content.Child = createContent(); - updateState(withAnimation: false); } protected override void LoadComplete() @@ -73,9 +72,6 @@ protected override void LoadComplete() base.LoadComplete(); dimLevel.BindValueChanged(_ => dimContainer.FadeColour(OsuColour.Gray(1 - dimLevel.Value), 500, Easing.OutQuint), true); - showStoryboard.BindValueChanged(_ => updateState()); - - updateState(withAnimation: false); } public override void OnEntering(ScreenTransitionEvent e) @@ -87,7 +83,7 @@ public override void OnEntering(ScreenTransitionEvent e) public override bool OnExiting(ScreenExitEvent e) { // The storyboard will do weird things with clock time changing on exit, so let's just hide it instead. - background?.UnloadStoryboard(); + background.UnloadStoryboard(); return base.OnExiting(e); } @@ -95,27 +91,15 @@ public override bool OnExiting(ScreenExitEvent e) public void RefreshBackgroundAsync() { cancellationTokenSource?.Cancel(); - LoadComponentAsync(createContent(), loaded => - { - content.Child = loaded; - updateState(withAnimation: false); - }, (cancellationTokenSource = new CancellationTokenSource()).Token); + LoadComponentAsync(createContent(), d => content.Child = d, (cancellationTokenSource = new CancellationTokenSource()).Token); } private Drawable createContent() => background = new BeatmapBackgroundWithStoryboard(beatmap.Value) { RelativeSizeAxes = Axes.Both, - StoryboardLoaded = () => updateState(withAnimation: false) + ShowStoryboard = { BindTarget = showStoryboard }, }; - private void updateState(bool withAnimation = true) - { - background?.Storyboard.FadeTo(showStoryboard.Value ? 1 : 0, withAnimation ? 500 : 0, Easing.OutQuint); - // if the storyboard is disabled, in some cases (e.g. involving `StoryboardReplacesBackground`) - // we still need to show the background sprite, because if we don't, then there will be no background shown at all - background?.Sprite.FadeTo(showStoryboard.Value ? 0 : 1, withAnimation ? 500 : 0, Easing.OutQuint); - } - public override bool Equals(BackgroundScreen? other) { if (other is not EditorBackgroundScreen otherBeatmapBackground)