// 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, Child = new DrawableTernaryButton { Current = Current, Description = "New combo", CreateIcon = () => new SpriteIcon { Icon = OsuIcon.EditorNewComboA }, }, }, pickerButton = new ColourPickerButton { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Alpha = 0, 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() { 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; } else { BackgroundColour = ComboColours[comboIndexFor(SelectedHitObject.Value, ComboColours)]; icon.Colour = OsuColour.ForegroundTextColourFor(BackgroundColour); icon.Blending = BlendingParameters.Inherit; } } 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; } }