diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs index 65eec740f0ea..3c5277a4d9ad 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Input; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; @@ -307,6 +308,46 @@ public void TestNodeSamplePopover() hitObjectNodeHasSampleVolume(0, 1, 10); } + [Test] + public void TestSamplePointSeek() + { + 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) }, + }, + RepeatCount = 1 + }); + }); + + seekSamplePiece(-1); + editorTimeIs(0); + samplePopoverIsOpen(); + seekSamplePiece(-1); + editorTimeIs(0); + samplePopoverIsOpen(); + seekSamplePiece(1); + editorTimeIs(406); + seekSamplePiece(1); + editorTimeIs(813); + seekSamplePiece(1); + editorTimeIs(1627); + seekSamplePiece(1); + editorTimeIs(1627); + } + [Test] public void TestHotkeysMultipleSelectionWithSameSampleBank() { @@ -626,7 +667,7 @@ public void TestSelectingObjectDoesNotMutateSamples() private void clickSamplePiece(int objectIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} sample piece", () => { - var samplePiece = this.ChildrenOfType().Single(piece => piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)); + var samplePiece = this.ChildrenOfType().Single(piece => piece is not NodeSamplePointPiece && piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)); InputManager.MoveMouseTo(samplePiece); InputManager.Click(MouseButton.Left); @@ -640,6 +681,21 @@ private void clickNodeSamplePiece(int objectIndex, int nodeIndex) => AddStep($"c InputManager.Click(MouseButton.Left); }); + private void seekSamplePiece(int direction) => AddStep($"seek sample piece {direction}", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(direction < 1 ? Key.Left : Key.Right); + InputManager.ReleaseKey(Key.ShiftLeft); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + private void samplePopoverIsOpen() => AddUntilStep("sample popover is open", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(o => o.IsPresent); + return popover != null; + }); + private void samplePopoverHasNoFocus() => AddUntilStep("sample popover textbox not focused", () => { var popover = this.ChildrenOfType().SingleOrDefault(); @@ -784,5 +840,7 @@ private void hitObjectNodeHasSampleAdditionBank(int objectIndex, int nodeIndex, 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 editorTimeIs(double time) => AddAssert($"editor time is {time}", () => Precision.AlmostEquals(EditorClock.CurrentTimeAccurate, time, 1)); } } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index ef0c60cd2060..aca0984e0fd4 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -147,6 +147,10 @@ public static IEnumerable GetGlobalActionsFor(GlobalActionCategory new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelLeft }, GlobalAction.EditorCycleNextBeatSnapDivisor), new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.EditorToggleRotateControl), new KeyBinding(new[] { InputKey.Control, InputKey.E }, GlobalAction.EditorToggleScaleControl), + new KeyBinding(new[] { InputKey.Control, InputKey.Left }, GlobalAction.EditorSeekToPreviousHitObject), + new KeyBinding(new[] { InputKey.Control, InputKey.Right }, GlobalAction.EditorSeekToNextHitObject), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.Left }, GlobalAction.EditorSeekToPreviousSamplePoint), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.Right }, GlobalAction.EditorSeekToNextSamplePoint), }; private static IEnumerable editorTestPlayKeyBindings => new[] @@ -456,6 +460,18 @@ public enum GlobalAction [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTestPlayQuickExitToCurrentTime))] EditorTestPlayQuickExitToCurrentTime, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToPreviousHitObject))] + EditorSeekToPreviousHitObject, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextHitObject))] + EditorSeekToNextHitObject, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToPreviousSamplePoint))] + EditorSeekToPreviousSamplePoint, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextSamplePoint))] + EditorSeekToNextSamplePoint, } public enum GlobalActionCategory diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 450585f79abf..206db1a16674 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -404,6 +404,26 @@ public static class GlobalActionKeyBindingStrings /// public static LocalisableString DecreaseModSpeed => new TranslatableString(getKey(@"decrease_mod_speed"), @"Decrease mod speed"); + /// + /// "Seek to previous hit object" + /// + public static LocalisableString EditorSeekToPreviousHitObject => new TranslatableString(getKey(@"editor_seek_to_previous_hit_object"), @"Seek to previous hit object"); + + /// + /// "Seek to next hit object" + /// + public static LocalisableString EditorSeekToNextHitObject => new TranslatableString(getKey(@"editor_seek_to_next_hit_object"), @"Seek to next hit object"); + + /// + /// "Seek to previous sample point" + /// + public static LocalisableString EditorSeekToPreviousSamplePoint => new TranslatableString(getKey(@"editor_seek_to_previous_sample_point"), @"Seek to previous sample point"); + + /// + /// "Seek to next sample point" + /// + public static LocalisableString EditorSeekToNextSamplePoint => new TranslatableString(getKey(@"editor_seek_to_next_sample_point"), @"Seek to next sample point"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs index 4ddbc9dd4876..2e8d86d4c7fd 100644 --- a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs +++ b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs @@ -23,15 +23,15 @@ public bool OnPressed(KeyBindingPressEvent e) { case GlobalAction.DecreaseVolume: case GlobalAction.IncreaseVolume: - ActionRequested?.Invoke(e.Action); - return true; + return ActionRequested?.Invoke(e.Action) == true; case GlobalAction.ToggleMute: case GlobalAction.NextVolumeMeter: case GlobalAction.PreviousVolumeMeter: if (!e.Repeat) - ActionRequested?.Invoke(e.Action); - return true; + return ActionRequested?.Invoke(e.Action) == true; + + return false; } return false; diff --git a/osu.Game/Overlays/VolumeOverlay.cs b/osu.Game/Overlays/VolumeOverlay.cs index 0d801ff118ed..bb2ad6069522 100644 --- a/osu.Game/Overlays/VolumeOverlay.cs +++ b/osu.Game/Overlays/VolumeOverlay.cs @@ -110,14 +110,18 @@ public bool Adjust(GlobalAction action, float amount = 1, bool isPrecise = false return true; case GlobalAction.NextVolumeMeter: - if (State.Value == Visibility.Visible) - volumeMeters.SelectNext(); + if (State.Value != Visibility.Visible) + return false; + + volumeMeters.SelectNext(); Show(); return true; case GlobalAction.PreviousVolumeMeter: - if (State.Value == Visibility.Visible) - volumeMeters.SelectPrevious(); + if (State.Value != Visibility.Visible) + return false; + + volumeMeters.SelectPrevious(); Show(); return true; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/NodeSamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/NodeSamplePointPiece.cs index 9cc1268db7ab..46e1ee2193b1 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/NodeSamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/NodeSamplePointPiece.cs @@ -22,6 +22,12 @@ public NodeSamplePointPiece(HitObject hitObject, int nodeIndex) NodeIndex = nodeIndex; } + protected override double GetTime() + { + var hasRepeats = (IHasRepeats)HitObject; + return HitObject.StartTime + hasRepeats.Duration * NodeIndex / hasRepeats.SpanCount(); + } + protected override IList GetSamples() { var hasRepeats = (IHasRepeats)HitObject; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index 8bfb0a33587d..a8cf8723f2d0 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; @@ -33,6 +34,12 @@ public partial class SamplePointPiece : HitObjectPointPiece, IHasPopover { public readonly HitObject HitObject; + [Resolved] + private EditorClock? editorClock { get; set; } + + [Resolved] + private Editor? editor { get; set; } + public SamplePointPiece(HitObject hitObject) { HitObject = hitObject; @@ -43,11 +50,32 @@ public SamplePointPiece(HitObject hitObject) protected override Color4 GetRepresentingColour(OsuColour colours) => AlternativeColor ? colours.Pink2 : colours.Pink1; + protected virtual double GetTime() => HitObject is IHasRepeats r ? HitObject.StartTime + r.Duration / r.SpanCount() / 2 : HitObject.StartTime; + [BackgroundDependencyLoader] private void load() { HitObject.DefaultsApplied += _ => updateText(); updateText(); + + if (editor != null) + editor.ShowSampleEditPopoverRequested += onShowSampleEditPopoverRequested; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editor != null) + editor.ShowSampleEditPopoverRequested -= onShowSampleEditPopoverRequested; + } + + private void onShowSampleEditPopoverRequested(double time) + { + if (!Precision.AlmostEquals(time, GetTime())) return; + + editorClock?.SeekSmoothlyTo(GetTime()); + this.ShowPopover(); } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 167ac9287486..9bb91af80652 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -44,6 +44,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Compose.Components.Timeline; @@ -224,6 +225,9 @@ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnl /// public Bindable ComposerFocusMode { get; } = new Bindable(); + [CanBeNull] + public event Action ShowSampleEditPopoverRequested; + public Editor(EditorLoader loader = null) { this.loader = loader; @@ -713,6 +717,26 @@ protected override bool OnScroll(ScrollEvent e) public bool OnPressed(KeyBindingPressEvent e) { + // Repeatable actions + switch (e.Action) + { + case GlobalAction.EditorSeekToPreviousHitObject: + seekHitObject(-1); + return true; + + case GlobalAction.EditorSeekToNextHitObject: + seekHitObject(1); + return true; + + case GlobalAction.EditorSeekToPreviousSamplePoint: + seekSamplePoint(-1); + return true; + + case GlobalAction.EditorSeekToNextSamplePoint: + seekSamplePoint(1); + return true; + } + if (e.Repeat) return false; @@ -750,10 +774,9 @@ public bool OnPressed(KeyBindingPressEvent e) case GlobalAction.EditorTestGameplay: bottomBar.TestGameplayButton.TriggerClick(); return true; - - default: - return false; } + + return false; } public void OnReleased(KeyBindingReleaseEvent e) @@ -1077,6 +1100,66 @@ private void seekControlPoint(int direction) clock.Seek(found.Time); } + private void seekHitObject(int direction) + { + var found = direction < 1 + ? editorBeatmap.HitObjects.LastOrDefault(p => p.StartTime < clock.CurrentTimeAccurate) + : editorBeatmap.HitObjects.FirstOrDefault(p => p.StartTime > clock.CurrentTimeAccurate); + + if (found != null) + clock.SeekSmoothlyTo(found.StartTime); + } + + private void seekSamplePoint(int direction) + { + double currentTime = clock.CurrentTimeAccurate; + + // Check if we are currently inside a hit object with node samples, if so seek to the next node sample point + var current = direction < 1 + ? editorBeatmap.HitObjects.LastOrDefault(p => p is IHasRepeats r && p.StartTime < currentTime && r.EndTime >= currentTime) + : editorBeatmap.HitObjects.LastOrDefault(p => p is IHasRepeats r && p.StartTime <= currentTime && r.EndTime > currentTime); + + if (current != null) + { + // Find the next node sample point + var r = (IHasRepeats)current; + double[] nodeSamplePointTimes = new double[r.RepeatCount + 3]; + + nodeSamplePointTimes[0] = current.StartTime; + // The sample point for the main samples is sandwiched between the head and the first repeat + nodeSamplePointTimes[1] = current.StartTime + r.Duration / r.SpanCount() / 2; + + for (int i = 0; i < r.SpanCount(); i++) + { + nodeSamplePointTimes[i + 2] = current.StartTime + r.Duration * (i + 1) / r.SpanCount(); + } + + double found = direction < 1 + ? nodeSamplePointTimes.Last(p => p < currentTime) + : nodeSamplePointTimes.First(p => p > currentTime); + + clock.SeekSmoothlyTo(found); + } + else + { + if (direction < 1) + { + current = editorBeatmap.HitObjects.LastOrDefault(p => p.StartTime < currentTime); + if (current != null) + clock.SeekSmoothlyTo(current is IHasRepeats r ? r.EndTime : current.StartTime); + } + else + { + current = editorBeatmap.HitObjects.FirstOrDefault(p => p.StartTime > currentTime); + if (current != null) + clock.SeekSmoothlyTo(current.StartTime); + } + } + + // Show the sample edit popover at the current time + ShowSampleEditPopoverRequested?.Invoke(clock.CurrentTimeAccurate); + } + private void seek(UIEvent e, int direction) { double amount = e.ShiftPressed ? 4 : 1;