From 6d24cfc6cb46af9e091dcde16621e8a24cc085eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 23 Jun 2026 10:36:34 +0200 Subject: [PATCH] Add presets to slider velocity control - Default presets match stable (0.75x, 1.00x, 1.50x). - Set a non-standard velocity and press the green "+" button to create a new preset. - Middle-click an existing preset to delete it. - Presets are stored to the `[Editor]` section of the `.osu`, therefore they are persistent when saved and will also be accessible to other people that open the map in the editor. --- ...ceneHitObjectDifficultyPointAdjustments.cs | 70 ++++++ osu.Game/Beatmaps/Beatmap.cs | 2 + osu.Game/Beatmaps/BeatmapConverter.cs | 1 + .../Beatmaps/Formats/LegacyBeatmapDecoder.cs | 8 + .../Beatmaps/Formats/LegacyBeatmapEncoder.cs | 1 + osu.Game/Beatmaps/IBeatmap.cs | 2 + .../Difficulty/DifficultyCalculator.cs | 6 + .../Timeline/DifficultyPointPiece.cs | 37 +-- osu.Game/Screens/Edit/EditorBeatmap.cs | 16 ++ .../Timing/SliderVelocityAdjustmentControl.cs | 215 +++++++++++++++--- 10 files changed, 296 insertions(+), 62 deletions(-) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs index 8858f5fd7c55..9792b5f66044 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs @@ -7,6 +7,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -144,6 +145,75 @@ public void TestMultipleSelectionWithDifferentSliderVelocity() hitObjectHasVelocity(1, 3); } + [Test] + public void TestPresetInteractions() + { + clickDifficultyPiece(0); + AddAssert("three presets displayed", + () => this.ChildrenOfType().Select(b => b.Velocity), + () => Is.EquivalentTo([0.75d, 1d, 1.5d])); + AddAssert("one preset selected", + () => this.ChildrenOfType().Count(b => b.Current.Value == TernaryState.True), + () => Is.EqualTo(1)); + AddAssert("selected preset is 1.0x", + () => this.ChildrenOfType().Single(b => b.Current.Value == TernaryState.True).Velocity, + () => Is.EqualTo(1)); + + AddStep("press first preset", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + hitObjectHasVelocity(0, 0.75); + + dismissPopover(); + + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + clickDifficultyPiece(0); + AddAssert("three presets displayed", + () => this.ChildrenOfType().Select(b => b.Velocity), + () => Is.EquivalentTo([0.75d, 1d, 1.5d])); + AddAssert("no preset fully selected", + () => this.ChildrenOfType().Count(b => b.Current.Value == TernaryState.True), + () => Is.EqualTo(0)); + AddAssert("one preset indeterminate", + () => this.ChildrenOfType().Count(b => b.Current.Value == TernaryState.Indeterminate), + () => Is.EqualTo(1)); + + AddStep("remove second preset", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Middle); + }); + AddAssert("two presets displayed", + () => this.ChildrenOfType().Select(b => b.Velocity), + () => Is.EquivalentTo([0.75d, 1.5d])); + hitObjectHasVelocity(0, 0.75); + hitObjectHasVelocity(1, 2); + + AddStep("press last preset", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Left); + }); + hitObjectHasVelocity(0, 1.5); + hitObjectHasVelocity(1, 1.5); + + setVelocityViaPopover(2); + hitObjectHasVelocity(0, 2); + hitObjectHasVelocity(1, 2); + + AddStep("add preset", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(); + InputManager.MoveMouseTo(popover.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("three presets displayed", + () => this.ChildrenOfType().Select(b => b.Velocity), + () => Is.EquivalentTo([0.75d, 1.5d, 2d])); + } + private void clickDifficultyPiece(int objectIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} difficulty piece", () => { var difficultyPiece = this.ChildrenOfType().Single(piece => piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)); diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index 97e8f3d2b757..ef408d5d0119 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -146,6 +146,8 @@ public double GetMostCommonBeatLength() public int[] Bookmarks { get; set; } = Array.Empty(); + public double[] SliderVelocityPresets { get; set; } = [0.75, 1, 1.5]; + public int BeatmapVersion { get; set; } = LegacyBeatmapEncoder.FIRST_LAZER_VERSION; IBeatmap IBeatmap.Clone() => Clone(); diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index b02d1f0b7ff9..a0907e22cf3a 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -85,6 +85,7 @@ protected virtual Beatmap ConvertBeatmap(IBeatmap original, CancellationToken beatmap.Countdown = original.Countdown; beatmap.CountdownOffset = original.CountdownOffset; beatmap.Bookmarks = original.Bookmarks; + beatmap.SliderVelocityPresets = original.SliderVelocityPresets; beatmap.BeatmapVersion = original.BeatmapVersion; return beatmap; diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 7ed2a91e5ad8..60cbc28d3a5f 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -321,6 +321,14 @@ private void handleEditor(string line) }).Where(p => p.result).Select(p => p.val).ToArray(); break; + case @"VelocityPresets": + beatmap.SliderVelocityPresets = pair.Value.Split(',').Select(v => + { + bool result = double.TryParse(v, out double val); + return new { result, val }; + }).Where(p => p.result).Select(p => p.val).ToArray(); + break; + case @"DistanceSpacing": beatmap.DistanceSpacing = Math.Max(0, Parsing.ParseDouble(pair.Value)); break; diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index 41db60b17e38..0c158edc1a24 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -129,6 +129,7 @@ private void handleEditor(TextWriter writer) writer.WriteLine(FormattableString.Invariant($"BeatDivisor: {beatmap.BeatmapInfo.BeatDivisor}")); writer.WriteLine(FormattableString.Invariant($"GridSize: {beatmap.GridSize}")); writer.WriteLine(FormattableString.Invariant($"TimelineZoom: {beatmap.TimelineZoom}")); + writer.WriteLine(FormattableString.Invariant($@"VelocityPresets: {string.Join(',', beatmap.SliderVelocityPresets)}")); } private void handleMetadata(TextWriter writer) diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index 7880457a69d6..4be8d1fda545 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -103,6 +103,8 @@ public interface IBeatmap int[] Bookmarks { get; internal set; } + double[] SliderVelocityPresets { get; internal set; } + int BeatmapVersion { get; } /// diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 9b28045ed30e..bd7f3c2d6b55 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -421,6 +421,12 @@ public int[] Bookmarks set => baseBeatmap.Bookmarks = value; } + public double[] SliderVelocityPresets + { + get => baseBeatmap.SliderVelocityPresets; + set => baseBeatmap.SliderVelocityPresets = value; + } + #endregion } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs index dde80e3539a7..e7ffb7b0d44f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs @@ -98,37 +98,8 @@ private void load() // if the piece belongs to an unselected object, operate on that object alone, independently of the selection. var relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).Where(o => o is IHasSliderVelocity).ToArray(); - // even if there are multiple objects selected, we can still display a value if they all have the same value. - var selectedPointBindable = relevantObjects.Select(point => ((IHasSliderVelocity)point).SliderVelocityMultiplier).Distinct().Count() == 1 - ? ((IHasSliderVelocity)relevantObjects.First()).SliderVelocityMultiplierBindable - : null; - - if (selectedPointBindable != null) - { - // there may be legacy control points, which contain infinite precision for compatibility reasons (see LegacyDifficultyControlPoint). - // generally that level of precision could only be set by externally editing the .osu file, so at the point - // a user is looking to update this within the editor it should be safe to obliterate this additional precision. - adjustmentControl.Current.Value = selectedPointBindable.Value; - } - else - { - adjustmentControl.IsMultipleValues = true; - } - - adjustmentControl.Current.BindValueChanged(val => - { - beatmap.BeginChange(); - - foreach (var h in relevantObjects) - { - ((IHasSliderVelocity)h).SliderVelocityMultiplier = val.NewValue; - beatmap.Update(h); - } - - beatmap.EndChange(); - - adjustmentControl.IsMultipleValues = false; - }); + adjustmentControl.ObjectsToAdjust.Clear(); + adjustmentControl.ObjectsToAdjust.AddRange(relevantObjects); } protected override void LoadComplete() @@ -141,9 +112,9 @@ protected override void LoadComplete() internal partial class SliderVelocityInspector : EditorInspector { - private readonly Bindable current; + private readonly IBindable current; - public SliderVelocityInspector(Bindable current) + public SliderVelocityInspector(IBindable current) { this.current = current; } diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 8e2bfc54fac5..58f1198fdfa1 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -132,6 +132,14 @@ public EditorBeatmap(IBeatmap playableBeatmap, ISkin beatmapSkin = null, Storybo EndChange(); }); + SliderVelocityPresets = new BindableList(playableBeatmap.SliderVelocityPresets); + SliderVelocityPresets.BindCollectionChanged((_, _) => + { + BeginChange(); + playableBeatmap.SliderVelocityPresets = SliderVelocityPresets.OrderBy(x => x).Distinct().ToArray(); + EndChange(); + }); + PreviewTime = new BindableInt(BeatmapInfo.Metadata.PreviewTime); PreviewTime.BindValueChanged(s => { @@ -292,6 +300,14 @@ int[] IBeatmap.Bookmarks set => PlayableBeatmap.Bookmarks = value; } + public readonly BindableList SliderVelocityPresets; + + double[] IBeatmap.SliderVelocityPresets + { + get => PlayableBeatmap.SliderVelocityPresets; + set => PlayableBeatmap.SliderVelocityPresets = value; + } + public int BeatmapVersion { get; set; } public IBeatmap Clone() => (EditorBeatmap)MemberwiseClone(); diff --git a/osu.Game/Screens/Edit/Timing/SliderVelocityAdjustmentControl.cs b/osu.Game/Screens/Edit/Timing/SliderVelocityAdjustmentControl.cs index 5b0c7915a5d4..1c4d548c1215 100644 --- a/osu.Game/Screens/Edit/Timing/SliderVelocityAdjustmentControl.cs +++ b/osu.Game/Screens/Edit/Timing/SliderVelocityAdjustmentControl.cs @@ -1,51 +1,51 @@ // 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.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osuTK; +using osuTK.Input; namespace osu.Game.Screens.Edit.Timing { - public partial class SliderVelocityAdjustmentControl : CompositeDrawable, IHasCurrentValue + public partial class SliderVelocityAdjustmentControl : CompositeDrawable { - public Bindable Current - { - get => current.Current; - set => current.Current = value; - } - - private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(1) + public Bindable Current { get; } = new BindableNumber(1) { Precision = 0.01, MinValue = 0.1, MaxValue = 10 }; - /// - /// This is a hack to allow the text box to show an indication that multiple slider velocity values are active - /// when the selection contains multiple objects with different velocities. - /// - public bool IsMultipleValues - { - get => isMultipleValues; - set - { - if (isMultipleValues == value) - return; + public BindableList ObjectsToAdjust { get; } = new BindableList(); - isMultipleValues = value; - updateIndeterminateState(); - } - } + public bool IsMultipleValues { get; private set; } - private bool isMultipleValues; + private bool applyingStateFromBeatmap; private FormDiscreteAdjustmentControl control = null!; + private FillFlowContainer presetsFlow = null!; + private RoundedButton addPresetButton = null!; + + private readonly BindableList presets = new BindableList(); + + [Resolved] + private EditorBeatmap beatmap { get; set; } = null!; [BackgroundDependencyLoader] private void load() @@ -65,9 +65,26 @@ private void load() { Caption = "Slider velocity", Current = Current, + }, + presetsFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Spacing = new Vector2(5), + Child = addPresetButton = new RoundedButton + { + Width = 50, + Height = 25, + Text = "+", + Action = () => presets.Add(Current.Value), + } } } }; + + presets.BindTo(beatmap.SliderVelocityPresets); + presetsFlow.SetLayoutPosition(addPresetButton, float.MinValue); } protected override void LoadComplete() @@ -79,17 +96,157 @@ protected override void LoadComplete() if (focused.NewValue && IsMultipleValues) control.TextBox.Text = string.Empty; }); - updateIndeterminateState(); + + beatmap.TransactionEnded += updateState; + beatmap.BeatmapReprocessed += updateState; + ObjectsToAdjust.BindCollectionChanged((_, _) => updateState(), true); + presets.BindCollectionChanged((_, _) => updatePresets(), true); + + Current.BindValueChanged(val => applyVelocity(val.NewValue)); } - private void updateIndeterminateState() + private void updateState() { + HashSet velocities = ObjectsToAdjust.OfType().Select(point => Math.Round(point.SliderVelocityMultiplier, 2)).Distinct().ToHashSet(); + IsMultipleValues = velocities.Count > 1; + + applyingStateFromBeatmap = true; + + control.Current.Value = velocities.FirstOrDefault(defaultValue: 1); + control.LabelFormat = IsMultipleValues ? static _ => "(multiple)" - : v => LocalisableString.Interpolate($"{v:0.00}x"); + : v => LocalisableString.Interpolate($@"{v:0.00}x"); control.TextBox.PlaceholderText = IsMultipleValues ? "(multiple)" : string.Empty; + + foreach (var preset in presetsFlow.OfType()) + { + if (velocities.Contains(preset.Velocity)) + preset.Current.Value = IsMultipleValues ? TernaryState.Indeterminate : TernaryState.True; + else + preset.Current.Value = TernaryState.False; + } + + addPresetButton.Enabled.Value = velocities.Count == 1 && !presets.Contains(velocities.Single()); + + applyingStateFromBeatmap = false; + } + + private void updatePresets() + { + var remainingPresets = presets.ToHashSet(); + + foreach (var button in presetsFlow.OfType()) + { + if (remainingPresets.Contains(button.Velocity)) + remainingPresets.Remove(button.Velocity); + else + button.Expire(); + } + + foreach (double preset in remainingPresets) + { + var presetButton = new SliderVelocityPresetTernaryButton(preset) + { + Description = default, + OnDelete = v => presets.Remove(v), + }; + presetButton.Current.BindValueChanged(val => + { + if (applyingStateFromBeatmap) + return; + + if (val.NewValue == TernaryState.True) + applyVelocity(preset); + else + updateState(); + }); + + presetsFlow.Add(presetButton); + presetsFlow.SetLayoutPosition(presetButton, (float)preset); + } + + updateState(); } - public bool TakeFocus() => GetContainingFocusManager()!.ChangeFocus(control.TextBox); + private void applyVelocity(double velocity) + { + if (applyingStateFromBeatmap) + return; + + beatmap.BeginChange(); + + foreach (var h in ObjectsToAdjust) + { + if (h is IHasSliderVelocity sv) + { + sv.SliderVelocityMultiplier = velocity; + beatmap.Update(h); + } + } + + beatmap.EndChange(); + } + + public bool TakeFocus() + { + if (IsMultipleValues) + control.TextBox.Text = string.Empty; + return GetContainingFocusManager()!.ChangeFocus(control.TextBox); + } + + protected override void Dispose(bool isDisposing) + { + if (beatmap.IsNotNull()) + { + beatmap.TransactionEnded -= updateState; + beatmap.BeatmapReprocessed -= updateState; + } + + base.Dispose(isDisposing); + } + + internal partial class SliderVelocityPresetTernaryButton : DrawableTernaryButton + { + public double Velocity { get; } + public Action? OnDelete { get; init; } + + public SliderVelocityPresetTernaryButton(double velocity) + { + Velocity = velocity; + CreateIcon = () => new Container + { + Child = new OsuSpriteText + { + Text = LocalisableString.Format($@"{velocity:0.00}x"), + Font = OsuFont.Style.Body.With(weight: FontWeight.Bold), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + RelativeSizeAxes = Axes.None; + Width = 50; + Height = 25; + } + + [BackgroundDependencyLoader] + private void load() + { + Icon.Position = Vector2.Zero; + Icon.RelativeSizeAxes = Axes.Both; + Icon.Size = new Vector2(1); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if ((e.ShiftPressed && e.Button == MouseButton.Right) || e.Button == MouseButton.Middle) + { + OnDelete?.Invoke(Velocity); + return true; + } + + return base.OnMouseDown(e); + } + } } }