diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml index 8ca9f38234..442b97c473 100644 --- a/.github/workflows/sentry-release.yml +++ b/.github/workflows/sentry-release.yml @@ -23,4 +23,4 @@ jobs: SENTRY_URL: https://sentry.ppy.sh/ with: environment: production - version: ${{ github.ref }} + version: osu@${{ github.ref_name }} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index 23500f5da6..79ff222a89 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Mods if (positionInfo == positionInfos.First()) { - positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2); + positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2); positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); } else diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs index da73c2addb..266f7d1251 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -116,6 +116,7 @@ namespace osu.Game.Rulesets.Osu.Utils if (!(osuObject is Slider slider)) return; + // No need to update the head and tail circles, since slider handles that when the new slider path is set slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); @@ -137,6 +138,7 @@ namespace osu.Game.Rulesets.Osu.Utils if (!(osuObject is Slider slider)) return; + // No need to update the head and tail circles, since slider handles that when the new slider path is set slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); @@ -146,5 +148,41 @@ namespace osu.Game.Rulesets.Osu.Utils slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); } + + /// + /// Rotate a slider about its start position by the specified angle. + /// + /// The slider to be rotated. + /// The angle, measured in radians, to rotate the slider by. + public static void RotateSlider(Slider slider, float rotation) + { + void rotateNestedObject(OsuHitObject nested) => nested.Position = rotateVector(nested.Position - slider.Position, rotation) + slider.Position; + + // No need to update the head and tail circles, since slider handles that when the new slider path is set + slider.NestedHitObjects.OfType().ForEach(rotateNestedObject); + slider.NestedHitObjects.OfType().ForEach(rotateNestedObject); + + var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(); + foreach (var point in controlPoints) + point.Position = rotateVector(point.Position, rotation); + + slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); + } + + /// + /// Rotate a vector by the specified angle. + /// + /// The vector to be rotated. + /// The angle, measured in radians, to rotate the vector by. + /// The rotated vector. + private static Vector2 rotateVector(Vector2 vector, float rotation) + { + float angle = MathF.Atan2(vector.Y, vector.X) + rotation; + float length = vector.Length; + return new Vector2( + length * MathF.Cos(angle), + length * MathF.Sin(angle) + ); + } } } diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs index d1bc3b45df..a77d1f8b0f 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics.Primitives; +using osu.Framework.Utils; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osuTK; @@ -37,15 +38,23 @@ namespace osu.Game.Rulesets.Osu.Utils foreach (OsuHitObject hitObject in hitObjects) { Vector2 relativePosition = hitObject.Position - previousPosition; - float absoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X); + float absoluteAngle = MathF.Atan2(relativePosition.Y, relativePosition.X); float relativeAngle = absoluteAngle - previousAngle; - positionInfos.Add(new ObjectPositionInfo(hitObject) + ObjectPositionInfo positionInfo; + positionInfos.Add(positionInfo = new ObjectPositionInfo(hitObject) { RelativeAngle = relativeAngle, DistanceFromPrevious = relativePosition.Length }); + if (hitObject is Slider slider) + { + float absoluteRotation = getSliderRotation(slider); + positionInfo.Rotation = absoluteRotation - absoluteAngle; + absoluteAngle = absoluteRotation; + } + previousPosition = hitObject.EndPosition; previousAngle = absoluteAngle; } @@ -70,7 +79,7 @@ namespace osu.Game.Rulesets.Osu.Utils if (hitObject is Spinner) { - previous = null; + previous = current; continue; } @@ -124,16 +133,23 @@ namespace osu.Game.Rulesets.Osu.Utils if (previous != null) { - Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre; - Vector2 relativePosition = previous.HitObject.Position - earliestPosition; - previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X); + if (previous.HitObject is Slider s) + { + previousAbsoluteAngle = getSliderRotation(s); + } + else + { + Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre; + Vector2 relativePosition = previous.HitObject.Position - earliestPosition; + previousAbsoluteAngle = MathF.Atan2(relativePosition.Y, relativePosition.X); + } } float absoluteAngle = previousAbsoluteAngle + current.PositionInfo.RelativeAngle; var posRelativeToPrev = new Vector2( - current.PositionInfo.DistanceFromPrevious * (float)Math.Cos(absoluteAngle), - current.PositionInfo.DistanceFromPrevious * (float)Math.Sin(absoluteAngle) + current.PositionInfo.DistanceFromPrevious * MathF.Cos(absoluteAngle), + current.PositionInfo.DistanceFromPrevious * MathF.Sin(absoluteAngle) ); Vector2 lastEndPosition = previous?.EndPositionModified ?? playfield_centre; @@ -141,6 +157,19 @@ namespace osu.Game.Rulesets.Osu.Utils posRelativeToPrev = RotateAwayFromEdge(lastEndPosition, posRelativeToPrev); current.PositionModified = lastEndPosition + posRelativeToPrev; + + if (!(current.HitObject is Slider slider)) + return; + + absoluteAngle = MathF.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X); + + Vector2 centreOfMassOriginal = calculateCentreOfMass(slider); + Vector2 centreOfMassModified = rotateVector(centreOfMassOriginal, current.PositionInfo.Rotation + absoluteAngle - getSliderRotation(slider)); + centreOfMassModified = RotateAwayFromEdge(current.PositionModified, centreOfMassModified); + + float relativeRotation = MathF.Atan2(centreOfMassModified.Y, centreOfMassModified.X) - MathF.Atan2(centreOfMassOriginal.Y, centreOfMassOriginal.X); + if (!Precision.AlmostEquals(relativeRotation, 0)) + RotateSlider(slider, relativeRotation); } /// @@ -172,13 +201,13 @@ namespace osu.Game.Rulesets.Osu.Utils var previousPosition = workingObject.PositionModified; // Clamp slider position to the placement area - // If the slider is larger than the playfield, force it to stay at the original position + // If the slider is larger than the playfield, at least make sure that the head circle is inside the playfield float newX = possibleMovementBounds.Width < 0 - ? workingObject.PositionOriginal.X + ? Math.Clamp(possibleMovementBounds.Left, 0, OsuPlayfield.BASE_SIZE.X) : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right); float newY = possibleMovementBounds.Height < 0 - ? workingObject.PositionOriginal.Y + ? Math.Clamp(possibleMovementBounds.Top, 0, OsuPlayfield.BASE_SIZE.Y) : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom); slider.Position = workingObject.PositionModified = new Vector2(newX, newY); @@ -287,6 +316,45 @@ namespace osu.Game.Rulesets.Osu.Utils ); } + /// + /// Estimate the centre of mass of a slider relative to its start position. + /// + /// The slider to process. + /// The centre of mass of the slider. + private static Vector2 calculateCentreOfMass(Slider slider) + { + const double sample_step = 50; + + // just sample the start and end positions if the slider is too short + if (slider.Distance <= sample_step) + { + return Vector2.Divide(slider.Path.PositionAt(1), 2); + } + + int count = 0; + Vector2 sum = Vector2.Zero; + double pathDistance = slider.Distance; + + for (double i = 0; i < pathDistance; i += sample_step) + { + sum += slider.Path.PositionAt(i / pathDistance); + count++; + } + + return sum / count; + } + + /// + /// Get the absolute rotation of a slider, defined as the angle from its start position to the end of its path. + /// + /// The slider to process. + /// The angle in radians. + private static float getSliderRotation(Slider slider) + { + var endPositionVector = slider.Path.PositionAt(1); + return MathF.Atan2(endPositionVector.Y, endPositionVector.X); + } + public class ObjectPositionInfo { /// @@ -309,6 +377,13 @@ namespace osu.Game.Rulesets.Osu.Utils /// public float DistanceFromPrevious { get; set; } + /// + /// The rotation of the hit object, relative to its jump angle. + /// For sliders, this is defined as the angle from the slider's start position to the end of its path, relative to its jump angle. + /// For hit circles and spinners, this property is ignored. + /// + public float Rotation { get; set; } + /// /// The hit object associated with this . /// diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs b/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs index 46b45979ea..8dd368f2a9 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs @@ -77,6 +77,12 @@ namespace osu.Game.Tests.Visual.Editing timingInfo.Text = $"offset: {selectedGroup.Value.Time:N2} bpm: {selectedGroup.Value.ControlPoints.OfType().First().BPM:N2}"; } + [Test] + public void TestNoop() + { + AddStep("do nothing", () => { }); + } + [Test] public void TestTapThenReset() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs index 17b8189fc7..a358166477 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs @@ -1,14 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Overlays; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Timing; +using osu.Game.Screens.Edit.Timing.RowAttributes; +using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { @@ -22,6 +26,8 @@ namespace osu.Game.Tests.Visual.Editing [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + private TimingScreen timingScreen; + protected override bool ScrollUsingMouseWheel => false; public TestSceneTimingScreen() @@ -36,12 +42,54 @@ namespace osu.Game.Tests.Visual.Editing Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); Beatmap.Disabled = true; - Child = new TimingScreen + Child = timingScreen = new TimingScreen { State = { Value = Visibility.Visible }, }; } + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Stop clock", () => Clock.Stop()); + + AddUntilStep("wait for rows to load", () => Child.ChildrenOfType().Any()); + } + + [Test] + public void TestTrackingCurrentTimeWhileRunning() + { + AddStep("Select first effect point", () => + { + InputManager.MoveMouseTo(Child.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670); + AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670); + + AddStep("Seek to just before next point", () => Clock.Seek(69000)); + AddStep("Start clock", () => Clock.Start()); + + AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670); + } + + [Test] + public void TestTrackingCurrentTimeWhilePaused() + { + AddStep("Select first effect point", () => + { + InputManager.MoveMouseTo(Child.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670); + AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670); + + AddStep("Seek to later", () => Clock.Seek(80000)); + AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670); + } + protected override void Dispose(bool isDisposing) { Beatmap.Disabled = false; diff --git a/osu.Game/Screens/Edit/BottomBar.cs b/osu.Game/Screens/Edit/BottomBar.cs index 5994184c11..62caaced89 100644 --- a/osu.Game/Screens/Edit/BottomBar.cs +++ b/osu.Game/Screens/Edit/BottomBar.cs @@ -2,13 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Game.Overlays; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components.Timelines.Summary; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Edit { @@ -26,6 +29,14 @@ namespace osu.Game.Screens.Edit Height = 60; + Masking = true; + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.2f), + Type = EdgeEffectType.Shadow, + Radius = 10f, + }; + InternalChildren = new Drawable[] { new Box diff --git a/osu.Game/Screens/Edit/EditorTable.cs b/osu.Game/Screens/Edit/EditorTable.cs index a67a060134..26819dcfe7 100644 --- a/osu.Game/Screens/Edit/EditorTable.cs +++ b/osu.Game/Screens/Edit/EditorTable.cs @@ -99,6 +99,15 @@ namespace osu.Game.Screens.Edit colourSelected = colours.Colour3; } + protected override void LoadComplete() + { + base.LoadComplete(); + + // Reduce flicker of rows when offset is being changed rapidly. + // Probably need to reconsider this. + FinishTransforms(true); + } + private bool selected; public bool Selected diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index 0c12eff503..77d875b67f 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -61,6 +61,7 @@ namespace osu.Game.Screens.Edit.Timing selectedGroup.BindValueChanged(group => { + // TODO: This should scroll the selected row into view. foreach (var b in BackgroundFlow) b.Selected = b.Item == group.NewValue; }, true); } diff --git a/osu.Game/Screens/Edit/Timing/EffectSection.cs b/osu.Game/Screens/Edit/Timing/EffectSection.cs index c8944d0357..c9f73411f1 100644 --- a/osu.Game/Screens/Edit/Timing/EffectSection.cs +++ b/osu.Game/Screens/Edit/Timing/EffectSection.cs @@ -31,18 +31,33 @@ namespace osu.Game.Screens.Edit.Timing }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + kiai.Current.BindValueChanged(_ => saveChanges()); + omitBarLine.Current.BindValueChanged(_ => saveChanges()); + scrollSpeedSlider.Current.BindValueChanged(_ => saveChanges()); + + void saveChanges() + { + if (!isRebinding) ChangeHandler?.SaveState(); + } + } + + private bool isRebinding; + protected override void OnControlPointChanged(ValueChangedEvent point) { if (point.NewValue != null) { + isRebinding = true; + kiai.Current = point.NewValue.KiaiModeBindable; - kiai.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); - omitBarLine.Current = point.NewValue.OmitFirstBarLineBindable; - omitBarLine.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); - scrollSpeedSlider.Current = point.NewValue.ScrollSpeedBindable; - scrollSpeedSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + + isRebinding = false; } } diff --git a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs index 57fcff6a4c..4143c5ea55 100644 --- a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs @@ -3,6 +3,8 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; @@ -31,12 +33,16 @@ namespace osu.Game.Screens.Edit.Timing private IAdjustableClock metronomeClock; + private Sample clunk; + [Resolved] private OverlayColourProvider overlayColourProvider { get; set; } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { + clunk = audio.Samples.Get(@"Multiplayer/countdown-tick"); + const float taper = 25; const float swing_vertical_offset = -23; const float lower_cover_height = 32; @@ -269,8 +275,21 @@ namespace osu.Game.Screens.Edit.Timing if (currentAngle != 0 && Math.Abs(currentAngle - targetAngle) > angle * 1.8f && isSwinging) { - using (stick.BeginDelayedSequence(beatLength / 2)) + using (BeginDelayedSequence(beatLength / 2)) + { stick.FlashColour(overlayColourProvider.Content1, beatLength, Easing.OutQuint); + + Schedule(() => + { + var channel = clunk?.GetChannel(); + + if (channel != null) + { + channel.Frequency.Value = RNG.NextDouble(0.98f, 1.02f); + channel.Play(); + } + }); + } } } } diff --git a/osu.Game/Screens/Edit/Timing/TapTimingControl.cs b/osu.Game/Screens/Edit/Timing/TapTimingControl.cs index d0ab4d1f98..990f8d2ce0 100644 --- a/osu.Game/Screens/Edit/Timing/TapTimingControl.cs +++ b/osu.Game/Screens/Edit/Timing/TapTimingControl.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -18,6 +19,9 @@ namespace osu.Game.Screens.Edit.Timing [Resolved] private EditorClock editorClock { get; set; } + [Resolved] + private EditorBeatmap beatmap { get; set; } + [Resolved] private Bindable selectedGroup { get; set; } @@ -45,6 +49,7 @@ namespace osu.Game.Screens.Edit.Timing { new Dimension(GridSizeMode.Absolute, 200), new Dimension(GridSizeMode.Absolute, 60), + new Dimension(GridSizeMode.Absolute, 60), }, Content = new[] { @@ -77,7 +82,36 @@ namespace osu.Game.Screens.Edit.Timing }, } } - } + }, + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Children = new Drawable[] + { + new TimingAdjustButton(1) + { + Text = "Offset", + RelativeSizeAxes = Axes.X, + Width = 0.48f, + Height = 50, + Action = adjustOffset, + }, + new TimingAdjustButton(0.1) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Text = "BPM", + RelativeSizeAxes = Axes.X, + Width = 0.48f, + Height = 50, + Action = adjustBpm, + } + } + }, }, new Drawable[] { @@ -113,6 +147,35 @@ namespace osu.Game.Screens.Edit.Timing }; } + private void adjustOffset(double adjust) + { + // VERY TEMPORARY + var currentGroupItems = selectedGroup.Value.ControlPoints.ToArray(); + + beatmap.ControlPointInfo.RemoveGroup(selectedGroup.Value); + + double newOffset = selectedGroup.Value.Time + adjust; + + foreach (var cp in currentGroupItems) + beatmap.ControlPointInfo.Add(newOffset, cp); + + // the control point might not necessarily exist yet, if currentGroupItems was empty. + selectedGroup.Value = beatmap.ControlPointInfo.GroupAt(newOffset, true); + + if (!editorClock.IsRunning) + editorClock.Seek(newOffset); + } + + private void adjustBpm(double adjust) + { + var timing = selectedGroup.Value.ControlPoints.OfType().FirstOrDefault(); + + if (timing == null) + return; + + timing.BeatLength = 60000 / (timing.BPM + adjust); + } + private void tap() { editorClock.Seek(selectedGroup.Value.Time); diff --git a/osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs b/osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs new file mode 100644 index 0000000000..9fc7e56a3d --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs @@ -0,0 +1,254 @@ +// 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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Framework.Threading; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Edit.Timing +{ + /// + /// A button with variable constant output based on hold position and length. + /// + public class TimingAdjustButton : CompositeDrawable + { + public Action Action; + + private readonly double adjustAmount; + private ScheduledDelegate adjustDelegate; + + private const int max_multiplier = 10; + + private const int adjust_levels = 4; + + private const double initial_delay = 300; + + private const double minimum_delay = 80; + + public Container Content { get; set; } + + private double adjustDelay = initial_delay; + + private readonly Box background; + + private readonly OsuSpriteText text; + + private Sample sample; + + public LocalisableString Text + { + get => text.Text; + set => text.Text = value; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + public TimingAdjustButton(double adjustAmount) + { + this.adjustAmount = adjustAmount; + + CornerRadius = 5; + Masking = true; + + AddInternal(Content = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(weight: FontWeight.SemiBold), + Padding = new MarginPadding(5), + Depth = float.MinValue + } + } + }); + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sample = audio.Samples.Get(@"UI/notch-tick"); + + background.Colour = colourProvider.Background3; + + for (int i = 1; i <= adjust_levels; i++) + { + Content.Add(new IncrementBox(i, adjustAmount)); + Content.Add(new IncrementBox(-i, adjustAmount)); + } + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + beginRepeat(); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + adjustDelegate?.Cancel(); + base.OnMouseUp(e); + } + + private void beginRepeat() + { + adjustDelegate?.Cancel(); + + adjustDelay = initial_delay; + adjustNext(); + + void adjustNext() + { + var hoveredBox = Content.OfType().FirstOrDefault(d => d.IsHovered); + + if (hoveredBox != null) + { + Action(adjustAmount * hoveredBox.Multiplier); + + adjustDelay = Math.Max(minimum_delay, adjustDelay * 0.9f); + + hoveredBox.Flash(); + + var channel = sample?.GetChannel(); + + if (channel != null) + { + double repeatModifier = 0.05f * (Math.Abs(adjustDelay - initial_delay) / minimum_delay); + double multiplierModifier = (hoveredBox.Multiplier / max_multiplier) * 0.2f; + + channel.Frequency.Value = 1 + multiplierModifier + repeatModifier; + channel.Play(); + } + } + else + { + adjustDelay = initial_delay; + } + + adjustDelegate = Scheduler.AddDelayed(adjustNext, adjustDelay); + } + } + + private class IncrementBox : CompositeDrawable + { + public readonly float Multiplier; + + private readonly Box box; + private readonly OsuSpriteText text; + + public IncrementBox(int index, double amount) + { + Multiplier = Math.Sign(index) * convertMultiplier(index); + + float ratio = (float)index / adjust_levels; + + RelativeSizeAxes = Axes.Both; + + Width = 0.5f * Math.Abs(ratio); + + Anchor direction = index < 0 ? Anchor.x2 : Anchor.x0; + + Origin |= direction; + + Depth = Math.Abs(index); + + Anchor = Anchor.TopCentre; + + InternalChildren = new Drawable[] + { + box = new Box + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive + }, + text = new OsuSpriteText + { + Anchor = direction, + Origin = direction, + Font = OsuFont.Default.With(size: 10, weight: FontWeight.Bold), + Text = $"{(index > 0 ? "+" : "-")}{Math.Abs(Multiplier * amount)}", + Padding = new MarginPadding(5), + Alpha = 0, + } + }; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + box.Colour = colourProvider.Background1; + box.Alpha = 0.1f; + } + + private float convertMultiplier(int m) + { + switch (Math.Abs(m)) + { + default: return 1; + + case 2: return 2; + + case 3: return 5; + + case 4: + return max_multiplier; + } + } + + protected override bool OnHover(HoverEvent e) + { + box.Colour = colourProvider.Colour0; + + box.FadeTo(0.2f, 100, Easing.OutQuint); + text.FadeIn(100, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + box.Colour = colourProvider.Background1; + + box.FadeTo(0.1f, 500, Easing.OutQuint); + text.FadeOut(100, Easing.OutQuint); + base.OnHoverLost(e); + } + + public void Flash() + { + box + .FadeTo(0.4f, 20, Easing.OutQuint) + .Then() + .FadeTo(0.2f, 400, Easing.OutQuint); + + text + .MoveToY(-5, 20, Easing.OutQuint) + .Then() + .MoveToY(0, 400, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs index f71a8d7d22..40e6e8082a 100644 --- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -19,7 +19,7 @@ namespace osu.Game.Screens.Edit.Timing public class TimingScreen : EditorScreenWithTimeline { [Cached] - private Bindable selectedGroup = new Bindable(); + public readonly Bindable SelectedGroup = new Bindable(); public TimingScreen() : base(EditorScreenMode.Timing) @@ -132,6 +132,40 @@ namespace osu.Game.Screens.Edit.Timing }, true); } + protected override void Update() + { + base.Update(); + + trackActivePoint(); + } + + /// + /// Given the user has selected a control point group, we want to track any group which is + /// active at the current point in time which matches the type the user has selected. + /// + /// So if the user is currently looking at a timing point and seeks into the future, a + /// future timing point would be automatically selected if it is now the new "current" point. + /// + private void trackActivePoint() + { + // For simplicity only match on the first type of the active control point. + var selectedPointType = selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType(); + + if (selectedPointType != null) + { + // We don't have an efficient way of looking up groups currently, only individual point types. + // To improve the efficiency of this in the future, we should reconsider the overall structure of ControlPointInfo. + + // Find the next group which has the same type as the selected one. + var found = Beatmap.ControlPointInfo.Groups + .Where(g => g.ControlPoints.Any(cp => cp.GetType() == selectedPointType)) + .LastOrDefault(g => g.Time <= clock.CurrentTimeAccurate); + + if (found != null) + selectedGroup.Value = found; + } + } + private void delete() { if (selectedGroup.Value == null) @@ -144,7 +178,27 @@ namespace osu.Game.Screens.Edit.Timing private void addNew() { - selectedGroup.Value = Beatmap.ControlPointInfo.GroupAt(clock.CurrentTime, true); + bool isFirstControlPoint = !Beatmap.ControlPointInfo.TimingPoints.Any(); + + var group = Beatmap.ControlPointInfo.GroupAt(clock.CurrentTime, true); + + if (isFirstControlPoint) + group.Add(new TimingControlPoint()); + else + { + // Try and create matching types from the currently selected control point. + var selected = selectedGroup.Value; + + if (selected != null) + { + foreach (var controlPoint in selected.ControlPoints) + { + group.Add(controlPoint.DeepClone()); + } + } + } + + selectedGroup.Value = group; } } } diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs index a5abd96d72..1a97058d73 100644 --- a/osu.Game/Screens/Edit/Timing/TimingSection.cs +++ b/osu.Game/Screens/Edit/Timing/TimingSection.cs @@ -28,15 +28,31 @@ namespace osu.Game.Screens.Edit.Timing }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + bpmTextEntry.Current.BindValueChanged(_ => saveChanges()); + timeSignature.Current.BindValueChanged(_ => saveChanges()); + + void saveChanges() + { + if (!isRebinding) ChangeHandler?.SaveState(); + } + } + + private bool isRebinding; + protected override void OnControlPointChanged(ValueChangedEvent point) { if (point.NewValue != null) { - bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable; - bpmTextEntry.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + isRebinding = true; + bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable; timeSignature.Current = point.NewValue.TimeSignatureBindable; - timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + + isRebinding = false; } } diff --git a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs index c80d3c4261..0745187e43 100644 --- a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; @@ -26,6 +27,8 @@ namespace osu.Game.Screens.Edit.Timing { private const int total_waveforms = 8; + private const float corner_radius = LabelledDrawable.CORNER_RADIUS; + private readonly BindableNumber beatLength = new BindableDouble(); [Resolved] @@ -42,18 +45,22 @@ namespace osu.Game.Screens.Edit.Timing private TimingControlPoint timingPoint = TimingControlPoint.DEFAULT; - private int lastDisplayedBeatIndex; + private double displayedTime; private double selectedGroupStartTime; private double selectedGroupEndTime; private readonly IBindableList controlPointGroups = new BindableList(); + private readonly BindableBool displayLocked = new BindableBool(); + + private LockedOverlay lockedOverlay = null!; + public WaveformComparisonDisplay() { RelativeSizeAxes = Axes.Both; - CornerRadius = LabelledDrawable.CORNER_RADIUS; + CornerRadius = corner_radius; Masking = true; } @@ -63,7 +70,7 @@ namespace osu.Game.Screens.Edit.Timing for (int i = 0; i < total_waveforms; i++) { - AddInternal(new WaveformRow + AddInternal(new WaveformRow(i == total_waveforms / 2) { RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Both, @@ -81,72 +88,112 @@ namespace osu.Game.Screens.Edit.Timing Width = 3, }); + AddInternal(lockedOverlay = new LockedOverlay()); + selectedGroup.BindValueChanged(_ => updateTimingGroup(), true); controlPointGroups.BindTo(editorBeatmap.ControlPointInfo.Groups); controlPointGroups.BindCollectionChanged((_, __) => updateTimingGroup()); - beatLength.BindValueChanged(_ => showFrom(lastDisplayedBeatIndex), true); + beatLength.BindValueChanged(_ => regenerateDisplay(true), true); + + displayLocked.BindValueChanged(locked => + { + if (locked.NewValue) + lockedOverlay.Show(); + else + lockedOverlay.Hide(); + }, true); } private void updateTimingGroup() { beatLength.UnbindBindings(); - selectedGroupStartTime = 0; - selectedGroupEndTime = beatmap.Value.Track.Length; - var tcp = selectedGroup.Value?.ControlPoints.OfType().FirstOrDefault(); if (tcp == null) { timingPoint = new TimingControlPoint(); + // During movement of a control point's offset, this clause can be hit momentarily, + // as moving a control point is implemented by removing it and inserting it at the new time. + // We don't want to reset the `selectedGroupStartTime` here as we rely on having the + // last value to update the waveform display below. + selectedGroupEndTime = beatmap.Value.Track.Length; return; } timingPoint = tcp; beatLength.BindTo(timingPoint.BeatLengthBindable); - selectedGroupStartTime = selectedGroup.Value?.Time ?? 0; + double? newStartTime = selectedGroup.Value?.Time; + double? offsetChange = newStartTime - selectedGroupStartTime; var nextGroup = editorBeatmap.ControlPointInfo.TimingPoints .SkipWhile(g => g != tcp) .Skip(1) .FirstOrDefault(); - if (nextGroup != null) - selectedGroupEndTime = nextGroup.Time; + selectedGroupStartTime = newStartTime ?? 0; + selectedGroupEndTime = nextGroup?.Time ?? beatmap.Value.Track.Length; + + if (newStartTime.HasValue && offsetChange.HasValue) + { + // The offset of the selected point may have changed. + // This handles the case the user has locked the view and expects the display to update with this change. + showFromTime(displayedTime + offsetChange.Value, true); + } } protected override bool OnHover(HoverEvent e) => true; protected override bool OnMouseMove(MouseMoveEvent e) { - float trackLength = (float)beatmap.Value.Track.Length; - int totalBeatsAvailable = (int)(trackLength / timingPoint.BeatLength); + if (!displayLocked.Value) + { + float trackLength = (float)beatmap.Value.Track.Length; + int totalBeatsAvailable = (int)(trackLength / timingPoint.BeatLength); - Scheduler.AddOnce(showFrom, (int)(e.MousePosition.X / DrawWidth * totalBeatsAvailable)); + Scheduler.AddOnce(showFromBeat, (int)(e.MousePosition.X / DrawWidth * totalBeatsAvailable)); + } return base.OnMouseMove(e); } + protected override bool OnClick(ClickEvent e) + { + displayLocked.Toggle(); + return true; + } + protected override void Update() { base.Update(); - if (!IsHovered) + if (!IsHovered && !displayLocked.Value) { int currentBeat = (int)Math.Floor((editorClock.CurrentTimeAccurate - selectedGroupStartTime) / timingPoint.BeatLength); - showFrom(currentBeat); + showFromBeat(currentBeat); } } - private void showFrom(int beatIndex) + private void showFromBeat(int beatIndex) => + showFromTime(selectedGroupStartTime + beatIndex * timingPoint.BeatLength, false); + + private void showFromTime(double time, bool animated) { - if (lastDisplayedBeatIndex == beatIndex) + if (displayedTime == time) return; + displayedTime = time; + regenerateDisplay(animated); + } + + private void regenerateDisplay(bool animated) + { + double index = (displayedTime - selectedGroupStartTime) / timingPoint.BeatLength; + // Chosen as a pretty usable number across all BPMs. // Optimally we'd want this to scale with the BPM in question, but performing // scaling of the display is both expensive in resampling, and decreases usability @@ -156,38 +203,115 @@ namespace osu.Game.Screens.Edit.Timing float trackLength = (float)beatmap.Value.Track.Length; float scale = trackLength / visible_width; + const int start_offset = total_waveforms / 2; + // Start displaying from before the current beat - beatIndex -= total_waveforms / 2; + index -= start_offset; foreach (var row in InternalChildren.OfType()) { // offset to the required beat index. - double time = selectedGroupStartTime + beatIndex * timingPoint.BeatLength; + double time = selectedGroupStartTime + index * timingPoint.BeatLength; float offset = (float)(time - visible_width / 2) / trackLength * scale; row.Alpha = time < selectedGroupStartTime || time > selectedGroupEndTime ? 0.2f : 1; - row.WaveformOffset = -offset; + row.WaveformOffsetTo(-offset, animated); row.WaveformScale = new Vector2(scale, 1); - row.BeatIndex = beatIndex++; + row.BeatIndex = (int)Math.Floor(index); + + index++; + } + } + + internal class LockedOverlay : CompositeDrawable + { + private OsuSpriteText text = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = corner_radius; + BorderColour = colours.Red; + BorderThickness = 3; + Alpha = 0; + + InternalChildren = new Drawable[] + { + new Box + { + AlwaysPresent = true, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = colours.Red, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Colour = colours.GrayF, + Text = "Locked", + Margin = new MarginPadding(5), + Shadow = false, + Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold), + } + } + }, + }; } - lastDisplayedBeatIndex = beatIndex; + public override void Show() + { + this.FadeIn(100, Easing.OutQuint); + + text + .FadeIn().Then().Delay(600) + .FadeOut().Then().Delay(600) + .Loop(); + } + + public override void Hide() + { + this.FadeOut(100, Easing.OutQuint); + } } internal class WaveformRow : CompositeDrawable { + private readonly bool isMainRow; private OsuSpriteText beatIndexText = null!; private WaveformGraph waveformGraph = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + public WaveformRow(bool isMainRow) + { + this.isMainRow = isMainRow; + } + [BackgroundDependencyLoader] private void load(IBindable beatmap) { InternalChildren = new Drawable[] { + new Box + { + Colour = colourProvider.Background3, + Alpha = isMainRow ? 1 : 0, + RelativeSizeAxes = Axes.Both, + }, waveformGraph = new WaveformGraph { RelativeSizeAxes = Axes.Both, @@ -212,7 +336,15 @@ namespace osu.Game.Screens.Edit.Timing public int BeatIndex { set => beatIndexText.Text = value.ToString(); } public Vector2 WaveformScale { set => waveformGraph.Scale = value; } - public float WaveformOffset { set => waveformGraph.X = value; } + + public void WaveformOffsetTo(float value, bool animated) => + this.TransformTo(nameof(waveformOffset), value, animated ? 300 : 0, Easing.OutQuint); + + private float waveformOffset + { + get => waveformGraph.X; + set => waveformGraph.X = value; + } } } } diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index 137bf7e0aa..ecc7fc4774 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -48,9 +48,8 @@ namespace osu.Game.Utils options.AutoSessionTracking = true; options.IsEnvironmentUser = false; - // The reported release needs to match release tags on github in order for sentry - // to automatically associate and track against releases. - options.Release = game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty); + // The reported release needs to match version as reported to Sentry in .github/workflows/sentry-release.yml + options.Release = $"osu@{game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty)}"; }); Logger.NewEntry += processLogEntry;