// 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 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; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Overlays; using osu.Game.Utils; using osuTK; namespace osu.Game.Screens.Edit.Timing { public partial class MetronomeDisplay : BeatSyncedContainer { private Container swing = null!; private OsuTextFlowContainer bpmText = null!; private Drawable weight = null!; private Drawable stick = null!; private IAdjustableClock metronomeClock = null!; private Sample? sampleLatch; private readonly MetronomeTick metronomeTick = new MetronomeTick(); [Resolved] private OverlayColourProvider overlayColourProvider { get; set; } = null!; [Resolved] private BindableBeatDivisor beatDivisor { get; set; } = null!; public bool EnableClicking { get => metronomeTick.EnableClicking; set => metronomeTick.EnableClicking = value; } public MetronomeDisplay() { AllowMistimedEventFiring = false; } [BackgroundDependencyLoader] private void load(AudioManager audio) { sampleLatch = audio.Samples.Get(@"UI/metronome-latch"); const float taper = 25; const float swing_vertical_offset = -23; const float lower_cover_height = 32; var triangleSize = new Vector2(90, 120 + taper); Margin = new MarginPadding(10); AutoSizeAxes = Axes.Both; metronomeTick.Ticked = onTickPlayed; InternalChildren = new Drawable[] { metronomeTick, new Container { Name = @"Taper adjust", Masking = true, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Size = new Vector2(triangleSize.X, triangleSize.Y - taper), Children = new Drawable[] { new Triangle { Name = @"Main body", EdgeSmoothness = new Vector2(1), Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Size = triangleSize, Colour = overlayColourProvider.Background3, }, }, }, new Circle { Name = "Centre marker", Colour = overlayColourProvider.Background5, RelativeSizeAxes = Axes.Y, Width = 2, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Y = -(lower_cover_height + 3), Height = 0.65f, }, swing = new Container { Name = @"Swing", RelativeSizeAxes = Axes.Both, Y = swing_vertical_offset, Height = 0.80f, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Children = new[] { stick = new Circle { Name = @"Stick", RelativeSizeAxes = Axes.Y, Colour = overlayColourProvider.Colour2, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Width = 4, }, weight = new Container { Name = @"Weight", Anchor = Anchor.TopCentre, Origin = Anchor.Centre, Size = new Vector2(10), Rotation = 180, RelativePositionAxes = Axes.Y, Y = 0.4f, Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, Shear = new Vector2(0.2f, 0), Colour = overlayColourProvider.Colour1, EdgeSmoothness = new Vector2(1), }, new Box { RelativeSizeAxes = Axes.Both, Shear = new Vector2(-0.2f, 0), Colour = overlayColourProvider.Colour1, EdgeSmoothness = new Vector2(1), }, new Circle { Anchor = Anchor.Centre, Origin = Anchor.Centre, Colour = ColourInfo.GradientVertical(overlayColourProvider.Colour1, overlayColourProvider.Colour0), RelativeSizeAxes = Axes.Y, Width = 1, Height = 0.9f }, } }, } }, new Container { Name = @"Taper adjust", Masking = true, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Size = new Vector2(triangleSize.X, triangleSize.Y - taper), Children = new Drawable[] { new Circle { Name = @"Locking wedge", Anchor = Anchor.TopCentre, Origin = Anchor.Centre, Colour = overlayColourProvider.Background1, Size = new Vector2(8), } }, }, new Circle { Name = @"Swing connection point", Y = swing_vertical_offset, Anchor = Anchor.BottomCentre, Origin = Anchor.Centre, Colour = overlayColourProvider.Colour0, Size = new Vector2(8) }, new Container { Name = @"Lower cover", Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, RelativeSizeAxes = Axes.X, Masking = true, Height = lower_cover_height, Children = new Drawable[] { new Triangle { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Size = triangleSize, Colour = overlayColourProvider.Background2, EdgeSmoothness = new Vector2(1), Alpha = 0.8f }, } }, bpmText = new OsuTextFlowContainer(st => { st.Font = OsuFont.Default.With(fixedWidth: true); st.Spacing = new Vector2(-2.2f, 0); }) { Name = @"BPM display", Colour = overlayColourProvider.Content1, AutoSizeAxes = Axes.Both, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Y = -3, }, }; Clock = new FramedClock(metronomeClock = new StopwatchClock(true)); } private double effectiveBeatLength; private double effectiveBpm => 60_000 / effectiveBeatLength; private TimingControlPoint timingPoint = null!; private bool isSwinging; private readonly BindableDouble interpolatedBpm = new BindableDouble(); private ScheduledDelegate? latchDelegate; private bool spedUp; private int computeSpedUpDivisor() { if (!spedUp) return 1; if (beatDivisor.Value % 3 == 0) return 3; if (beatDivisor.Value % 2 == 0) return 2; return 1; } protected override void LoadComplete() { base.LoadComplete(); interpolatedBpm.BindValueChanged(_ => updateBpmText()); } private void updateBpmText() { int intPart = (int)interpolatedBpm.Value; bpmText.Text = intPart.ToLocalisableString(); // While interpolating between two integer values, showing the decimal places would look a bit odd // so rounding is applied until we're close to the final value. int decimalPlaces = FormatUtils.FindPrecision((decimal)effectiveBpm); if (decimalPlaces > 0) { bool reachedFinalNumber = intPart == (int)effectiveBpm; bpmText.AddText((effectiveBpm % 1).ToLocalisableString("." + new string('0', decimalPlaces)), cp => cp.Alpha = reachedFinalNumber ? 0.5f : 0.1f); } } protected override void Update() { base.Update(); if (BeatSyncSource.ControlPoints == null) return; metronomeClock.Rate = IsBeatSyncedWithTrack ? BeatSyncSource.Clock.Rate : 1; timingPoint = BeatSyncSource.ControlPoints.TimingPointAt(BeatSyncSource.Clock.CurrentTime); Divisor = metronomeTick.Divisor = computeSpedUpDivisor(); if (effectiveBeatLength != timingPoint.BeatLength / Divisor) { effectiveBeatLength = timingPoint.BeatLength / Divisor; EarlyActivationMilliseconds = timingPoint.BeatLength / 2; float bpmRatio = (float)Interpolation.ApplyEasing(Easing.OutQuad, Math.Clamp((effectiveBpm - 30) / 480, 0, 1)); weight.MoveToY((float)Interpolation.Lerp(0.1f, 0.83f, bpmRatio), 600, Easing.OutQuint); this.TransformBindableTo(interpolatedBpm, effectiveBpm, 300, Easing.OutExpo); } if (!BeatSyncSource.Clock.IsRunning && isSwinging) { swing.ClearTransforms(true); isSwinging = false; // instantly latch if pendulum arm is close enough to center (to prevent awkward delayed playback of latch sound) if (Precision.AlmostEquals(swing.Rotation, 0, 1)) { swing.RotateTo(0, 60, Easing.OutQuint); stick.FadeColour(overlayColourProvider.Colour2, 1000, Easing.OutQuint); sampleLatch?.Play(); return; } using (BeginDelayedSequence(350)) { swing.RotateTo(0, 1000, Easing.OutQuint); stick.FadeColour(overlayColourProvider.Colour2, 1000, Easing.OutQuint); using (BeginDelayedSequence(380)) latchDelegate = Schedule(() => sampleLatch?.Play()); } } } protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); const float angle = 27.5f; if (!IsBeatSyncedWithTrack) return; isSwinging = true; latchDelegate?.Cancel(); latchDelegate = null; float currentAngle = swing.Rotation; float targetAngle = currentAngle > 0 ? -angle : angle; swing.RotateTo(targetAngle, effectiveBeatLength, Easing.InOutQuad); } private void onTickPlayed() { // Originally, this flash only occurred when the pendulum correctly passess the centre. // Mappers weren't happy with the metronome tick not playing immediately after starting playback // so now this matches the actual tick sample. stick.FlashColour(overlayColourProvider.Content1, effectiveBeatLength, Easing.OutQuint); } protected override bool OnKeyDown(KeyDownEvent e) { updateDivisorFromKey(e); return base.OnKeyDown(e); } protected override void OnKeyUp(KeyUpEvent e) { base.OnKeyUp(e); updateDivisorFromKey(e); } private void updateDivisorFromKey(UIEvent e) => spedUp = e.ControlPressed; private partial class MetronomeTick : BeatSyncedContainer { public bool EnableClicking; private Sample? sampleTick; private Sample? sampleTickDownbeat; public Action? Ticked; public MetronomeTick() { AllowMistimedEventFiring = false; } [BackgroundDependencyLoader] private void load(AudioManager audio) { sampleTick = audio.Samples.Get(@"UI/metronome-tick"); sampleTickDownbeat = audio.Samples.Get(@"UI/metronome-tick-downbeat"); } protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); if (!IsBeatSyncedWithTrack || !EnableClicking) return; var channel = beatIndex % timingPoint.TimeSignature.Numerator == 0 ? sampleTickDownbeat?.GetChannel() : sampleTick?.GetChannel(); if (channel == null) return; channel.Frequency.Value = RNG.NextDouble(0.98f, 1.02f); channel.Play(); Ticked?.Invoke(); } } } }