diff --git a/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs b/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs index d6e0f995e5..fe14935479 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs @@ -36,6 +36,12 @@ namespace osu.Game.Tests.Visual.Settings AddStep("set visual mode to circles", () => latencyCertifier.VisualMode.Value = LatencyVisualMode.CircleGameplay); } + [Test] + public void TestScrollingGameplay() + { + AddStep("set visual mode to scrolling", () => latencyCertifier.VisualMode.Value = LatencyVisualMode.ScrollingGameplay); + } + [Test] public void TestCycleVisualModes() { diff --git a/osu.Game/Screens/Utility/LatencyArea.cs b/osu.Game/Screens/Utility/LatencyArea.cs index 3b9e95ce16..21688f0b0c 100644 --- a/osu.Game/Screens/Utility/LatencyArea.cs +++ b/osu.Game/Screens/Utility/LatencyArea.cs @@ -108,6 +108,20 @@ namespace osu.Game.Screens.Utility }; break; + case LatencyVisualMode.ScrollingGameplay: + visualContent.Children = new Drawable[] + { + new ScrollingGameplay + { + RelativeSizeAxes = Axes.Both, + }, + new LatencyCursorContainer + { + RelativeSizeAxes = Axes.Both, + }, + }; + break; + default: throw new ArgumentOutOfRangeException(); } diff --git a/osu.Game/Screens/Utility/LatencyVisualMode.cs b/osu.Game/Screens/Utility/LatencyVisualMode.cs index f9f51f0829..cc15f79be8 100644 --- a/osu.Game/Screens/Utility/LatencyVisualMode.cs +++ b/osu.Game/Screens/Utility/LatencyVisualMode.cs @@ -7,6 +7,7 @@ namespace osu.Game.Screens.Utility public enum LatencyVisualMode { CircleGameplay, + ScrollingGameplay, Simple, } } diff --git a/osu.Game/Screens/Utility/ScrollingGameplay.cs b/osu.Game/Screens/Utility/ScrollingGameplay.cs new file mode 100644 index 0000000000..a32fd3b37f --- /dev/null +++ b/osu.Game/Screens/Utility/ScrollingGameplay.cs @@ -0,0 +1,201 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Utility.SampleComponents; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Utility +{ + public class ScrollingGameplay : LatencySampleComponent + { + private const float judgement_position = 0.8f; + private const float bar_height = 20; + + private int nextLocation; + + private readonly List hitEvents = new List(); + + private double? lastGeneratedBeatTime; + + private Container circles = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + new Box + { + Name = "judgement bar", + Colour = OverlayColourProvider.Content2, + RelativeSizeAxes = Axes.X, + RelativePositionAxes = Axes.Y, + Y = judgement_position, + Height = bar_height, + }, + circles = new Container + { + RelativeSizeAxes = Axes.Both, + }, + }; + + SampleBPM.BindValueChanged(_ => + { + circles.Clear(); + lastGeneratedBeatTime = null; + }); + } + + protected override void UpdateAtLimitedRate(InputState inputState) + { + double beatLength = 60000 / SampleBPM.Value; + + int nextBeat = (int)(Clock.CurrentTime / beatLength) + 1; + + // We want to generate a few hit objects ahead of the current time (to allow them to animate). + double generateUpTo = (nextBeat + 2) * beatLength; + + while (lastGeneratedBeatTime == null || lastGeneratedBeatTime < generateUpTo) + { + double time = ++nextBeat * beatLength; + + if (time <= lastGeneratedBeatTime) + continue; + + newBeat(time); + lastGeneratedBeatTime = time; + } + } + + private void newBeat(double time) + { + const float columns = 4; + + float adjustedXPos = ((1f + nextLocation++ % columns) - columns / 2) / columns; + + circles.Add(new SampleNote(time) + { + RelativePositionAxes = Axes.Both, + X = 0.5f + SampleVisualSpacing.Value * (adjustedXPos * 0.5f), + Scale = new Vector2(0.4f + (0.8f * SampleVisualSpacing.Value), 1), + Hit = hit, + }); + } + + private void hit(HitEvent h) + { + hitEvents.Add(h); + } + + public class SampleNote : LatencySampleComponent + { + public HitEvent? HitEvent; + + public Action? Hit { get; set; } + + public readonly double HitTime; + + private Box box = null!; + + private const float size = 100; + private const float duration = 200; + + public SampleNote(double hitTime) + { + HitTime = hitTime; + + Origin = Anchor.Centre; + AutoSizeAxes = Axes.Both; + AlwaysPresent = true; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = OverlayColourProvider.Content1, + Size = new Vector2(size, bar_height), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (!IsActive.Value) + return false; + + if (Math.Abs(Clock.CurrentTime - HitTime) > duration) + return false; + + // Allow using any key that isn't used by the latency certifier itself. + switch (e.Key) + { + case Key.Space: + case Key.Number1: + case Key.Number2: + case Key.Tab: + return false; + } + + attemptHit(); + return true; + } + + protected override void UpdateAtLimitedRate(InputState inputState) + { + if (HitEvent == null) + { + double preempt = (float)IBeatmapDifficultyInfo.DifficultyRange(SampleApproachRate.Value, 1800, 1200, 450); + + Alpha = (float)MathHelper.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); + Y = judgement_position - (float)((HitTime - Clock.CurrentTime) / preempt); + + if (Clock.CurrentTime > HitTime + duration) + Expire(); + } + } + + private void attemptHit() => Schedule(() => + { + if (HitEvent != null) + return; + + // in case it was hit outside of display range, show immediately + // so the user isn't confused. + this.FadeIn(); + + box + .FadeOut(duration / 2) + .ScaleTo(1.5f, duration / 2); + + HitEvent = new HitEvent(Clock.CurrentTime - HitTime, HitResult.Good, new HitObject + { + HitWindows = new HitWindows(), + }, null, null); + + Hit?.Invoke(HitEvent.Value); + + this.Delay(duration).Expire(); + }); + } + } +}