diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index dade129038..46ccdeeaa0 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -76,6 +76,8 @@ namespace osu.Game.Rulesets.Catch.Objects { base.CreateNestedHitObjects(cancellationToken); + this.PopulateNodeSamples(); + var dropletSamples = Samples.Select(s => s.With(@"slidertick")).ToList(); int nodeIndex = 0; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index 4600db8174..b18c77e8ee 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -156,6 +156,7 @@ namespace osu.Game.Rulesets.Osu.Tests { slider = (DrawableSlider)createSlider(repeats: 1); Add(slider); + slider.HitObject.NodeSamples.Clear(); }); AddStep("change samples", () => slider.HitObject.Samples = new[] diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index cc3ffd376e..f542b7b5af 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -252,6 +252,8 @@ namespace osu.Game.Rulesets.Osu.Objects protected void UpdateNestedSamples() { + this.PopulateNodeSamples(); + // TODO: remove this when guaranteed sort is present for samples (https://github.com/ppy/osu/issues/1933) HitSampleInfo tickSample = (Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) ?? Samples.FirstOrDefault())?.With("slidertick"); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs index 0e12ed68e4..f02d2a1bb1 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs @@ -13,9 +13,12 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Edit.Timing; using osu.Game.Tests.Beatmaps; @@ -79,10 +82,10 @@ namespace osu.Game.Tests.Visual.Editing } [Test] - public void TestPopoverHasFocus() + public void TestPopoverHasNoFocus() { clickSamplePiece(0); - samplePopoverHasFocus(); + samplePopoverHasNoFocus(); } [Test] @@ -226,6 +229,84 @@ namespace osu.Game.Tests.Visual.Editing samplePopoverHasSingleBank(HitSampleInfo.BANK_NORMAL); } + [Test] + public void TestPopoverAddSampleAddition() + { + clickSamplePiece(0); + + setBankViaPopover(HitSampleInfo.BANK_SOFT); + hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + + toggleAdditionViaPopover(0); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + + setAdditionBankViaPopover(HitSampleInfo.BANK_DRUM); + + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + + toggleAdditionViaPopover(0); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + } + + [Test] + public void TestNodeSamplePopover() + { + AddStep("add slider", () => + { + EditorBeatmap.Clear(); + EditorBeatmap.Add(new Slider + { + Position = new Vector2(256, 256), + StartTime = 0, + Path = new SliderPath(new[] { new PathControlPoint(Vector2.Zero), new PathControlPoint(new Vector2(250, 0)) }), + Samples = + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + }, + NodeSamples = + { + new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }, + new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }, + } + }); + }); + + clickNodeSamplePiece(0, 1); + + setBankViaPopover(HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleBank(0, 0, HitSampleInfo.BANK_NORMAL); + hitObjectNodeHasSampleBank(0, 1, HitSampleInfo.BANK_SOFT); + + toggleAdditionViaPopover(0); + + hitObjectNodeHasSampleBank(0, 0, HitSampleInfo.BANK_NORMAL); + hitObjectNodeHasSampleBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(0, 0, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSamples(0, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + + setAdditionBankViaPopover(HitSampleInfo.BANK_DRUM); + + hitObjectNodeHasSampleBank(0, 0, HitSampleInfo.BANK_NORMAL); + hitObjectNodeHasSampleNormalBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleAdditionBank(0, 1, HitSampleInfo.BANK_DRUM); + + toggleAdditionViaPopover(0); + + hitObjectNodeHasSampleBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(0, 0, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSamples(0, 1, HitSampleInfo.HIT_NORMAL); + + setVolumeViaPopover(10); + + hitObjectNodeHasSampleVolume(0, 0, 100); + hitObjectNodeHasSampleVolume(0, 1, 10); + } + [Test] public void TestHotkeysMultipleSelectionWithSameSampleBank() { @@ -329,13 +410,21 @@ namespace osu.Game.Tests.Visual.Editing InputManager.Click(MouseButton.Left); }); - private void samplePopoverHasFocus() => AddUntilStep("sample popover textbox focused", () => + private void clickNodeSamplePiece(int objectIndex, int nodeIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node sample piece", () => + { + var samplePiece = this.ChildrenOfType().Where(piece => piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)).ToArray()[nodeIndex]; + + InputManager.MoveMouseTo(samplePiece); + InputManager.Click(MouseButton.Left); + }); + + private void samplePopoverHasNoFocus() => AddUntilStep("sample popover textbox not focused", () => { var popover = this.ChildrenOfType().SingleOrDefault(); var slider = popover?.ChildrenOfType>().Single(); var textbox = slider?.ChildrenOfType().Single(); - return textbox?.HasFocus == true; + return textbox?.HasFocus == false; }); private void samplePopoverHasSingleVolume(int volume) => AddUntilStep($"sample popover has volume {volume}", () => @@ -372,7 +461,6 @@ namespace osu.Game.Tests.Visual.Editing private void dismissPopover() { - AddStep("unfocus textbox", () => InputManager.Key(Key.Escape)); AddStep("dismiss popover", () => InputManager.Key(Key.Escape)); AddUntilStep("wait for dismiss", () => !this.ChildrenOfType().Any(popover => popover.IsPresent)); } @@ -390,6 +478,12 @@ namespace osu.Game.Tests.Visual.Editing return h.Samples.All(o => o.Volume == volume); }); + private void hitObjectNodeHasSampleVolume(int objectIndex, int nodeIndex, int volume) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has volume {volume}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].All(o => o.Volume == volume); + }); + private void setBankViaPopover(string bank) => AddStep($"set bank {bank} via popover", () => { var popover = this.ChildrenOfType().Single(); @@ -401,6 +495,26 @@ namespace osu.Game.Tests.Visual.Editing InputManager.Key(Key.Enter); }); + private void setAdditionBankViaPopover(string bank) => AddStep($"set addition bank {bank} via popover", () => + { + var popover = this.ChildrenOfType().Single(); + var textBox = popover.ChildrenOfType().ToArray()[1]; + textBox.Current.Value = bank; + // force a commit via keyboard. + // this is needed when testing attempting to set empty bank - which should revert to the previous value, but only on commit. + ((IFocusManager)InputManager).ChangeFocus(textBox); + InputManager.Key(Key.Enter); + }); + + private void toggleAdditionViaPopover(int index) => AddStep($"toggle addition {index} via popover", () => + { + var popover = this.ChildrenOfType().First(); + var ternaryButton = popover.ChildrenOfType().ToArray()[index]; + InputManager.MoveMouseTo(ternaryButton); + InputManager.PressButton(MouseButton.Left); + InputManager.ReleaseButton(MouseButton.Left); + }); + private void hitObjectHasSamples(int objectIndex, params string[] samples) => AddAssert($"{objectIndex.ToOrdinalWords()} has samples {string.Join(',', samples)}", () => { var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); @@ -412,5 +526,41 @@ namespace osu.Game.Tests.Visual.Editing var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); return h.Samples.All(o => o.Bank == bank); }); + + private void hitObjectHasSampleNormalBank(int objectIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} has normal bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); + return h.Samples.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); + }); + + private void hitObjectHasSampleAdditionBank(int objectIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} has addition bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); + return h.Samples.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); + }); + + private void hitObjectNodeHasSamples(int objectIndex, int nodeIndex, params string[] samples) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has samples {string.Join(',', samples)}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].Select(s => s.Name).SequenceEqual(samples); + }); + + private void hitObjectNodeHasSampleBank(int objectIndex, int nodeIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].All(o => o.Bank == bank); + }); + + private void hitObjectNodeHasSampleNormalBank(int objectIndex, int nodeIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has normal bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); + }); + + private void hitObjectNodeHasSampleAdditionBank(int objectIndex, int nodeIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has addition bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); + }); } } diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index f71fa95aa7..c547a7a718 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -224,8 +224,16 @@ namespace osu.Game.Rulesets.Objects /// A populated . public HitSampleInfo CreateHitSampleInfo(string sampleName = HitSampleInfo.HIT_NORMAL) { - if (Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) is HitSampleInfo existingSample) - return existingSample.With(newName: sampleName); + // As per stable, all non-normal "addition" samples should use the same bank. + if (sampleName != HitSampleInfo.HIT_NORMAL) + { + if (Samples.FirstOrDefault(s => s.Name != HitSampleInfo.HIT_NORMAL) is HitSampleInfo existingAddition) + return existingAddition.With(newName: sampleName); + } + + // Fall back to using the normal sample bank otherwise. + if (Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) is HitSampleInfo existingNormal) + return existingNormal.With(newName: sampleName); return new HitSampleInfo(sampleName); } diff --git a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs index 2a4215b960..9677ac4fbd 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs @@ -3,6 +3,7 @@ using osu.Game.Audio; using System.Collections.Generic; +using System.Linq; namespace osu.Game.Rulesets.Objects.Types { @@ -45,5 +46,19 @@ namespace osu.Game.Rulesets.Objects.Types public static IList GetNodeSamples(this T obj, int nodeIndex) where T : HitObject, IHasRepeats => nodeIndex < obj.NodeSamples.Count ? obj.NodeSamples[nodeIndex] : obj.Samples; + + /// + /// Ensures that the list of node samples is at least as long as the number of nodes. + /// + /// The . + public static void PopulateNodeSamples(this T obj) + where T : HitObject, IHasRepeats + { + if (obj.NodeSamples.Count >= obj.RepeatCount + 2) + return; + + while (obj.NodeSamples.Count < obj.RepeatCount + 2) + obj.NodeSamples.Add(obj.Samples.Select(o => o.With()).ToList()); + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 4fba798a26..f65ca43dad 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -228,7 +228,7 @@ namespace osu.Game.Screens.Edit.Compose.Components yield return new TernaryButton(NewCombo, "New combo", () => new SpriteIcon { Icon = OsuIcon.EditorNewComboA }); foreach (var kvp in SelectionHandler.SelectionSampleStates) - yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => getIconForSample(kvp.Key)); + yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => GetIconForSample(kvp.Key)); } private IEnumerable createSampleBankTernaryButtons() @@ -264,7 +264,7 @@ namespace osu.Game.Screens.Edit.Compose.Components }; } - private Drawable getIconForSample(string sampleName) + public static Drawable GetIconForSample(string sampleName) { switch (sampleName) { diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index 7420362e19..7c30b73122 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -227,6 +227,7 @@ namespace osu.Game.Screens.Edit.Compose.Components return; h.Samples.Add(h.CreateHitSampleInfo(sampleName)); + EditorBeatmap.Update(h); }); } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index dd6bd43f4d..b68d5cd540 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -313,7 +313,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Given a selection target and a function of truth, retrieve the correct ternary state for display. /// - protected static TernaryState GetStateFromSelection(IEnumerable selection, Func func) + public static TernaryState GetStateFromSelection(IEnumerable selection, Func func) { if (selection.Any(func)) return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/NodeSamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/NodeSamplePointPiece.cs new file mode 100644 index 0000000000..ae3838bc41 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/NodeSamplePointPiece.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 System.Collections.Generic; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Audio; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public partial class NodeSamplePointPiece : SamplePointPiece + { + public readonly int NodeIndex; + + public NodeSamplePointPiece(HitObject hitObject, int nodeIndex) + : base(hitObject) + { + if (hitObject is not IHasRepeats) + throw new System.ArgumentException($"HitObject must implement {nameof(IHasRepeats)}", nameof(hitObject)); + + NodeIndex = nodeIndex; + } + + protected override IList GetSamples() + { + var hasRepeats = (IHasRepeats)HitObject; + return NodeIndex < hasRepeats.NodeSamples.Count ? hasRepeats.NodeSamples[NodeIndex] : HitObject.Samples; + } + + public override Popover GetPopover() => new NodeSampleEditPopover(HitObject, NodeIndex); + + public partial class NodeSampleEditPopover : SampleEditPopover + { + private readonly int nodeIndex; + + protected override IList GetRelevantSamples(HitObject ho) + { + if (ho is not IHasRepeats hasRepeats) + return ho.Samples; + + return nodeIndex < hasRepeats.NodeSamples.Count ? hasRepeats.NodeSamples[nodeIndex] : ho.Samples; + } + + public NodeSampleEditPopover(HitObject hitObject, int nodeIndex) + : base(hitObject) + { + this.nodeIndex = nodeIndex; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index 5c4a9faaca..930b78b468 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -1,8 +1,10 @@ // 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 Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -14,12 +16,15 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Screens.Edit.Timing; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { @@ -27,20 +32,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public readonly HitObject HitObject; - private readonly BindableList samplesBindable; - public SamplePointPiece(HitObject hitObject) { HitObject = hitObject; - samplesBindable = hitObject.SamplesBindable.GetBoundCopy(); } - protected override Color4 GetRepresentingColour(OsuColour colours) => colours.Pink; + public bool AlternativeColor { get; init; } + + protected override Color4 GetRepresentingColour(OsuColour colours) => AlternativeColor ? colours.Pink2 : colours.Pink1; [BackgroundDependencyLoader] private void load() { - samplesBindable.BindCollectionChanged((_, _) => updateText(), true); + HitObject.DefaultsApplied += _ => updateText(); + updateText(); } protected override bool OnClick(ClickEvent e) @@ -51,12 +56,28 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateText() { - Label.Text = $"{GetBankValue(samplesBindable)} {GetVolumeValue(samplesBindable)}"; + Label.Text = $"{abbreviateBank(GetBankValue(GetSamples()))} {GetVolumeValue(GetSamples())}"; + } + + private static string? abbreviateBank(string? bank) + { + return bank switch + { + HitSampleInfo.BANK_NORMAL => @"N", + HitSampleInfo.BANK_SOFT => @"S", + HitSampleInfo.BANK_DRUM => @"D", + _ => bank + }; } public static string? GetBankValue(IEnumerable samples) { - return samples.FirstOrDefault()?.Bank; + return samples.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL)?.Bank; + } + + public static string? GetAdditionBankValue(IEnumerable samples) + { + return samples.FirstOrDefault(o => o.Name != HitSampleInfo.HIT_NORMAL)?.Bank; } public static int GetVolumeValue(ICollection samples) @@ -64,15 +85,36 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return samples.Count == 0 ? 0 : samples.Max(o => o.Volume); } - public Popover GetPopover() => new SampleEditPopover(HitObject); + /// + /// Gets the samples to be edited by this sample point piece. + /// This could be the samples of the hit object itself, or of one of the nested hit objects. For example a slider repeat. + /// + /// The samples to be edited. + protected virtual IList GetSamples() => HitObject.Samples; + + public virtual Popover GetPopover() => new SampleEditPopover(HitObject); public partial class SampleEditPopover : OsuPopover { private readonly HitObject hitObject; private LabelledTextBox bank = null!; + private LabelledTextBox additionBank = null!; private IndeterminateSliderWithTextBoxInput volume = null!; + private FillFlowContainer togglesCollection = null!; + + private HitObject[] relevantObjects = null!; + private IList[] allRelevantSamples = null!; + + /// + /// Gets the sub-set of samples relevant to this sample point piece. + /// For example, to edit node samples this should return the samples at the index of the node. + /// + /// The hit object to get the relevant samples from. + /// The relevant list of samples. + protected virtual IList GetRelevantSamples(HitObject ho) => ho.Samples; + [Resolved(canBeNull: true)] private EditorBeatmap beatmap { get; set; } = null!; @@ -96,10 +138,21 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Spacing = new Vector2(0, 10), Children = new Drawable[] { + togglesCollection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 5), + }, bank = new LabelledTextBox { Label = "Bank Name", }, + additionBank = new LabelledTextBox + { + Label = "Addition Bank", + }, volume = new IndeterminateSliderWithTextBoxInput("Volume", new BindableInt(100) { MinValue = DrawableHitObject.MINIMUM_SAMPLE_VOLUME, @@ -110,89 +163,273 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }; bank.TabbableContentContainer = flow; + additionBank.TabbableContentContainer = flow; volume.TabbableContentContainer = flow; // if the piece belongs to a currently selected object, assume that the user wants to change all selected objects. // 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()).ToArray(); - var relevantSamples = relevantObjects.Select(h => h.Samples).ToArray(); + relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).ToArray(); + allRelevantSamples = relevantObjects.Select(GetRelevantSamples).ToArray(); // even if there are multiple objects selected, we can still display sample volume or bank if they all have the same value. - string? commonBank = getCommonBank(relevantSamples); - if (!string.IsNullOrEmpty(commonBank)) - bank.Current.Value = commonBank; - - int? commonVolume = getCommonVolume(relevantSamples); + int? commonVolume = getCommonVolume(); if (commonVolume != null) volume.Current.Value = commonVolume.Value; - updateBankPlaceholderText(relevantObjects); + updatePrimaryBankState(); bank.Current.BindValueChanged(val => { - updateBankFor(relevantObjects, val.NewValue); - updateBankPlaceholderText(relevantObjects); + if (string.IsNullOrEmpty(val.NewValue)) + return; + + setBank(val.NewValue); + updatePrimaryBankState(); }); // on commit, ensure that the value is correct by sourcing it from the objects' samples again. // this ensures that committing empty text causes a revert to the previous value. - bank.OnCommit += (_, _) => bank.Current.Value = getCommonBank(relevantSamples); + bank.OnCommit += (_, _) => updatePrimaryBankState(); - volume.Current.BindValueChanged(val => updateVolumeFor(relevantObjects, val.NewValue)); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - ScheduleAfterChildren(() => GetContainingFocusManager().ChangeFocus(volume)); - } - - private static string? getCommonBank(IList[] relevantSamples) => relevantSamples.Select(GetBankValue).Distinct().Count() == 1 ? GetBankValue(relevantSamples.First()) : null; - private static int? getCommonVolume(IList[] relevantSamples) => relevantSamples.Select(GetVolumeValue).Distinct().Count() == 1 ? GetVolumeValue(relevantSamples.First()) : null; - - private void updateBankFor(IEnumerable objects, string? newBank) - { - if (string.IsNullOrEmpty(newBank)) - return; - - beatmap.BeginChange(); - - foreach (var h in objects) + updateAdditionBankState(); + additionBank.Current.BindValueChanged(val => { - for (int i = 0; i < h.Samples.Count; i++) - { - h.Samples[i] = h.Samples[i].With(newBank: newBank); - } + if (string.IsNullOrEmpty(val.NewValue)) + return; - beatmap.Update(h); - } + setAdditionBank(val.NewValue); + updateAdditionBankState(); + }); + additionBank.OnCommit += (_, _) => updateAdditionBankState(); - beatmap.EndChange(); + volume.Current.BindValueChanged(val => + { + if (val.NewValue != null) + setVolume(val.NewValue.Value); + }); + + createStateBindables(); + updateTernaryStates(); + togglesCollection.AddRange(createTernaryButtons().Select(b => new DrawableTernaryButton(b) { RelativeSizeAxes = Axes.None, Size = new Vector2(40, 40) })); } - private void updateBankPlaceholderText(IEnumerable objects) + private string? getCommonBank() => allRelevantSamples.Select(GetBankValue).Distinct().Count() == 1 ? GetBankValue(allRelevantSamples.First()) : null; + private string? getCommonAdditionBank() => allRelevantSamples.Select(GetAdditionBankValue).Where(o => o is not null).Distinct().Count() == 1 ? GetAdditionBankValue(allRelevantSamples.First()) : null; + private int? getCommonVolume() => allRelevantSamples.Select(GetVolumeValue).Distinct().Count() == 1 ? GetVolumeValue(allRelevantSamples.First()) : null; + + private void updatePrimaryBankState() { - string? commonBank = getCommonBank(objects.Select(h => h.Samples).ToArray()); + string? commonBank = getCommonBank(); + bank.Current.Value = commonBank; bank.PlaceholderText = string.IsNullOrEmpty(commonBank) ? "(multiple)" : string.Empty; } - private void updateVolumeFor(IEnumerable objects, int? newVolume) + private void updateAdditionBankState() { - if (newVolume == null) - return; + string? commonAdditionBank = getCommonAdditionBank(); + additionBank.PlaceholderText = string.IsNullOrEmpty(commonAdditionBank) ? "(multiple)" : string.Empty; + additionBank.Current.Value = commonAdditionBank; + bool anyAdditions = allRelevantSamples.Any(o => o.Any(s => s.Name != HitSampleInfo.HIT_NORMAL)); + if (anyAdditions) + additionBank.Show(); + else + additionBank.Hide(); + } + + /// + /// Applies the given update action on all samples of + /// and invokes the necessary update notifiers for the beatmap and hit objects. + /// + /// The action to perform on each element of . + private void updateAllRelevantSamples(Action> updateAction) + { beatmap.BeginChange(); - foreach (var h in objects) + foreach (var relevantHitObject in relevantObjects) { - for (int i = 0; i < h.Samples.Count; i++) - { - h.Samples[i] = h.Samples[i].With(newVolume: newVolume.Value); - } - - beatmap.Update(h); + var relevantSamples = GetRelevantSamples(relevantHitObject); + updateAction(relevantHitObject, relevantSamples); + beatmap.Update(relevantHitObject); } beatmap.EndChange(); } + + private void setBank(string newBank) + { + updateAllRelevantSamples((_, relevantSamples) => + { + for (int i = 0; i < relevantSamples.Count; i++) + { + if (relevantSamples[i].Name != HitSampleInfo.HIT_NORMAL) continue; + + relevantSamples[i] = relevantSamples[i].With(newBank: newBank); + } + }); + } + + private void setAdditionBank(string newBank) + { + updateAllRelevantSamples((_, relevantSamples) => + { + for (int i = 0; i < relevantSamples.Count; i++) + { + if (relevantSamples[i].Name == HitSampleInfo.HIT_NORMAL) continue; + + relevantSamples[i] = relevantSamples[i].With(newBank: newBank); + } + }); + } + + private void setVolume(int newVolume) + { + updateAllRelevantSamples((_, relevantSamples) => + { + for (int i = 0; i < relevantSamples.Count; i++) + { + relevantSamples[i] = relevantSamples[i].With(newVolume: newVolume); + } + }); + } + + #region hitsound toggles + + private readonly Dictionary> selectionSampleStates = new Dictionary>(); + + private readonly List banks = new List(); + + private void createStateBindables() + { + foreach (string sampleName in HitSampleInfo.AllAdditions) + { + var bindable = new Bindable + { + Description = sampleName.Replace("hit", string.Empty).Titleize() + }; + + bindable.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + removeHitSample(sampleName); + break; + + case TernaryState.True: + addHitSample(sampleName); + break; + } + }; + + selectionSampleStates[sampleName] = bindable; + } + + banks.AddRange(HitSampleInfo.AllBanks); + } + + private void updateTernaryStates() + { + foreach ((string sampleName, var bindable) in selectionSampleStates) + { + bindable.Value = SelectionHandler.GetStateFromSelection(relevantObjects, h => GetRelevantSamples(h).Any(s => s.Name == sampleName)); + } + } + + private IEnumerable createTernaryButtons() + { + foreach ((string sampleName, var bindable) in selectionSampleStates) + yield return new TernaryButton(bindable, string.Empty, () => ComposeBlueprintContainer.GetIconForSample(sampleName)); + } + + private void addHitSample(string sampleName) + { + if (string.IsNullOrEmpty(sampleName)) + return; + + updateAllRelevantSamples((h, relevantSamples) => + { + // Make sure there isn't already an existing sample + if (relevantSamples.Any(s => s.Name == sampleName)) + return; + + // First try inheriting the sample info from the node samples instead of the samples of the hitobject + var relevantSample = relevantSamples.FirstOrDefault(s => s.Name != HitSampleInfo.HIT_NORMAL) ?? relevantSamples.FirstOrDefault(); + relevantSamples.Add(relevantSample?.With(sampleName) ?? h.CreateHitSampleInfo(sampleName)); + }); + + updateAdditionBankState(); + } + + private void removeHitSample(string sampleName) + { + if (string.IsNullOrEmpty(sampleName)) + return; + + updateAllRelevantSamples((_, relevantSamples) => + { + for (int i = 0; i < relevantSamples.Count; i++) + { + if (relevantSamples[i].Name == sampleName) + relevantSamples.RemoveAt(i--); + } + }); + + updateAdditionBankState(); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.ControlPressed || e.AltPressed || e.SuperPressed || !checkRightToggleFromKey(e.Key, out int rightIndex)) + return base.OnKeyDown(e); + + if (e.ShiftPressed) + { + string? newBank = banks.ElementAtOrDefault(rightIndex); + + if (string.IsNullOrEmpty(newBank)) + return true; + + setBank(newBank); + updatePrimaryBankState(); + setAdditionBank(newBank); + updateAdditionBankState(); + } + else + { + var item = togglesCollection.ElementAtOrDefault(rightIndex); + + if (item is not DrawableTernaryButton button) return base.OnKeyDown(e); + + button.Button.Toggle(); + } + + return true; + } + + private bool checkRightToggleFromKey(Key key, out int index) + { + switch (key) + { + case Key.W: + index = 0; + break; + + case Key.E: + index = 1; + break; + + case Key.R: + index = 2; + break; + + default: + index = -1; + break; + } + + return index >= 0; + } + + #endregion } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 7e6e886ff7..753856199a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -50,6 +50,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly Border border; private readonly Container colouredComponents; + private readonly Container sampleComponents; private readonly OsuSpriteText comboIndexText; private readonly SamplePointPiece samplePointPiece; private readonly DifficultyPointPiece? difficultyPointPiece; @@ -107,7 +108,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline samplePointPiece = new SamplePointPiece(Item) { Anchor = Anchor.BottomLeft, - Origin = Anchor.TopCentre + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.X, + AlternativeColor = Item is IHasRepeats + }, + sampleComponents = new Container + { + RelativeSizeAxes = Axes.Both, }, }); @@ -236,6 +243,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline X = (float)(i + 1) / (repeats.RepeatCount + 1) }); } + + // Add node sample pieces + sampleComponents.Clear(); + + for (int i = 0; i < repeats.RepeatCount + 2; i++) + { + sampleComponents.Add(new NodeSamplePointPiece(Item, i) + { + X = (float)i / (repeats.RepeatCount + 1), + RelativePositionAxes = Axes.X, + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopCentre + }); + } + + samplePointPiece.X = 1f / (repeats.RepeatCount + 1) / 2; } protected override bool ShouldBeConsideredForInput(Drawable child) => true;