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] 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) {