diff --git a/osu.Android.props b/osu.Android.props
index aad8cf10d0..155a21bacb 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,7 +52,7 @@
-
+
diff --git a/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs b/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs
new file mode 100644
index 0000000000..af6681e9cf
--- /dev/null
+++ b/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs
@@ -0,0 +1,73 @@
+// 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.Linq;
+using NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Screens.Utility;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.Settings
+{
+ public class TestSceneLatencyCertifierScreen : ScreenTestScene
+ {
+ private LatencyCertifierScreen latencyCertifier = null!;
+
+ public override void SetUpSteps()
+ {
+ base.SetUpSteps();
+
+ AddStep("Load screen", () => LoadScreen(latencyCertifier = new LatencyCertifierScreen()));
+ AddUntilStep("wait for load", () => latencyCertifier.IsLoaded);
+ }
+
+ [Test]
+ public void TestCertification()
+ {
+ checkDifficulty(1);
+ clickUntilResults(true);
+ continueFromResults();
+ checkDifficulty(2);
+
+ clickUntilResults(false);
+ continueFromResults();
+ checkDifficulty(1);
+
+ clickUntilResults(true);
+ AddAssert("check at results", () => !latencyCertifier.ChildrenOfType().Any());
+ AddAssert("check no buttons", () => !latencyCertifier.ChildrenOfType().Any());
+ checkDifficulty(1);
+ }
+
+ private void continueFromResults()
+ {
+ AddAssert("check at results", () => !latencyCertifier.ChildrenOfType().Any());
+ AddStep("hit enter to continue", () => InputManager.Key(Key.Enter));
+ }
+
+ private void checkDifficulty(int difficulty)
+ {
+ AddAssert($"difficulty is {difficulty}", () => latencyCertifier.DifficultyLevel == difficulty);
+ }
+
+ private void clickUntilResults(bool clickCorrect)
+ {
+ AddUntilStep("click correct button until results", () =>
+ {
+ var latencyArea = latencyCertifier
+ .ChildrenOfType()
+ .SingleOrDefault(a => clickCorrect ? a.TargetFrameRate == null : a.TargetFrameRate != null);
+
+ // reached results
+ if (latencyArea == null)
+ return true;
+
+ latencyArea.ChildrenOfType().Single().TriggerClick();
+ return false;
+ });
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs
index 8833420523..2b845e9d6b 100644
--- a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs
@@ -9,6 +9,7 @@ using osu.Framework.Screens;
using osu.Game.Localisation;
using osu.Game.Screens;
using osu.Game.Screens.Import;
+using osu.Game.Screens.Utility;
namespace osu.Game.Overlays.Settings.Sections.DebugSettings
{
@@ -30,13 +31,18 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings
{
LabelText = DebugSettingsStrings.BypassFrontToBackPass,
Current = config.GetBindable(DebugSetting.BypassFrontToBackPass)
+ },
+ new SettingsButton
+ {
+ Text = DebugSettingsStrings.ImportFiles,
+ Action = () => performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen()))
+ },
+ new SettingsButton
+ {
+ Text = @"Run latency certifier",
+ Action = () => performer?.PerformFromScreen(menu => menu.Push(new LatencyCertifierScreen()))
}
};
- Add(new SettingsButton
- {
- Text = DebugSettingsStrings.ImportFiles,
- Action = () => performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen()))
- });
}
}
}
diff --git a/osu.Game/Screens/Utility/ButtonWithKeyBind.cs b/osu.Game/Screens/Utility/ButtonWithKeyBind.cs
new file mode 100644
index 0000000000..ef87e0bca7
--- /dev/null
+++ b/osu.Game/Screens/Utility/ButtonWithKeyBind.cs
@@ -0,0 +1,53 @@
+// 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 osu.Framework.Allocation;
+using osu.Framework.Input.Events;
+using osu.Framework.Localisation;
+using osu.Game.Graphics;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Settings;
+using osuTK.Input;
+
+namespace osu.Game.Screens.Utility
+{
+ public class ButtonWithKeyBind : SettingsButton
+ {
+ private readonly Key key;
+
+ public ButtonWithKeyBind(Key key)
+ {
+ this.key = key;
+ }
+
+ public new LocalisableString Text
+ {
+ get => base.Text;
+ set => base.Text = $"{value} (Press {key.ToString().Replace("Number", string.Empty)})";
+ }
+
+ protected override bool OnKeyDown(KeyDownEvent e)
+ {
+ if (!e.Repeat && e.Key == key)
+ {
+ TriggerClick();
+ return true;
+ }
+
+ return base.OnKeyDown(e);
+ }
+
+ [Resolved]
+ private OverlayColourProvider overlayColourProvider { get; set; } = null!;
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ Height = 100;
+ SpriteText.Colour = overlayColourProvider.Background6;
+ SpriteText.Font = OsuFont.TorusAlternate.With(size: 34);
+ }
+ }
+}
diff --git a/osu.Game/Screens/Utility/LatencyArea.cs b/osu.Game/Screens/Utility/LatencyArea.cs
new file mode 100644
index 0000000000..2ef48bb571
--- /dev/null
+++ b/osu.Game/Screens/Utility/LatencyArea.cs
@@ -0,0 +1,241 @@
+// 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 osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input;
+using osu.Framework.Input.Events;
+using osu.Game.Overlays;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Screens.Utility
+{
+ public class LatencyArea : CompositeDrawable
+ {
+ [Resolved]
+ private OverlayColourProvider overlayColourProvider { get; set; } = null!;
+
+ public Action? ReportUserBest { get; set; }
+
+ private Drawable? background;
+
+ private readonly Key key;
+
+ public readonly int? TargetFrameRate;
+
+ public readonly BindableBool IsActiveArea = new BindableBool();
+
+ public LatencyArea(Key key, int? targetFrameRate)
+ {
+ this.key = key;
+ TargetFrameRate = targetFrameRate;
+
+ RelativeSizeAxes = Axes.Both;
+ Masking = true;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ InternalChildren = new[]
+ {
+ background = new Box
+ {
+ Colour = overlayColourProvider.Background6,
+ RelativeSizeAxes = Axes.Both,
+ },
+ new ButtonWithKeyBind(key)
+ {
+ Text = "Feels better",
+ Y = 20,
+ Width = 0.8f,
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.TopCentre,
+ Action = () => ReportUserBest?.Invoke(),
+ },
+ new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ new LatencyMovableBox(IsActiveArea)
+ {
+ RelativeSizeAxes = Axes.Both,
+ },
+ new LatencyCursorContainer(IsActiveArea)
+ {
+ RelativeSizeAxes = Axes.Both,
+ },
+ }
+ },
+ };
+
+ IsActiveArea.BindValueChanged(active =>
+ {
+ background.FadeColour(active.NewValue ? overlayColourProvider.Background4 : overlayColourProvider.Background6, 200, Easing.OutQuint);
+ }, true);
+ }
+
+ protected override bool OnMouseMove(MouseMoveEvent e)
+ {
+ IsActiveArea.Value = true;
+ return base.OnMouseMove(e);
+ }
+
+ private double lastFrameTime;
+
+ public override bool UpdateSubTree()
+ {
+ double elapsed = Clock.CurrentTime - lastFrameTime;
+ if (TargetFrameRate.HasValue && elapsed < 1000.0 / TargetFrameRate)
+ return false;
+
+ lastFrameTime = Clock.CurrentTime;
+
+ return base.UpdateSubTree();
+ }
+
+ public class LatencyMovableBox : CompositeDrawable
+ {
+ private Box box = null!;
+ private InputManager inputManager = null!;
+
+ private readonly BindableBool isActive;
+
+ [Resolved]
+ private OverlayColourProvider overlayColourProvider { get; set; } = null!;
+
+ public LatencyMovableBox(BindableBool isActive)
+ {
+ this.isActive = isActive;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ inputManager = GetContainingInputManager();
+
+ InternalChild = box = new Box
+ {
+ Size = new Vector2(40),
+ RelativePositionAxes = Axes.Both,
+ Position = new Vector2(0.5f),
+ Origin = Anchor.Centre,
+ Colour = overlayColourProvider.Colour1,
+ };
+ }
+
+ protected override bool OnHover(HoverEvent e) => false;
+
+ private double? lastFrameTime;
+
+ protected override void Update()
+ {
+ base.Update();
+
+ if (!isActive.Value)
+ {
+ lastFrameTime = null;
+ box.Colour = overlayColourProvider.Colour1;
+ return;
+ }
+
+ if (lastFrameTime != null)
+ {
+ float movementAmount = (float)(Clock.CurrentTime - lastFrameTime) / 400;
+
+ var buttons = inputManager.CurrentState.Keyboard.Keys;
+
+ box.Colour = buttons.HasAnyButtonPressed ? overlayColourProvider.Content1 : overlayColourProvider.Colour1;
+
+ foreach (var key in buttons)
+ {
+ switch (key)
+ {
+ case Key.K:
+ case Key.Up:
+ box.Y = MathHelper.Clamp(box.Y - movementAmount, 0.1f, 0.9f);
+ break;
+
+ case Key.J:
+ case Key.Down:
+ box.Y = MathHelper.Clamp(box.Y + movementAmount, 0.1f, 0.9f);
+ break;
+
+ case Key.Z:
+ case Key.Left:
+ box.X = MathHelper.Clamp(box.X - movementAmount, 0.1f, 0.9f);
+ break;
+
+ case Key.X:
+ case Key.Right:
+ box.X = MathHelper.Clamp(box.X + movementAmount, 0.1f, 0.9f);
+ break;
+ }
+ }
+ }
+
+ lastFrameTime = Clock.CurrentTime;
+ }
+ }
+
+ public class LatencyCursorContainer : CompositeDrawable
+ {
+ private Circle cursor = null!;
+ private InputManager inputManager = null!;
+
+ private readonly BindableBool isActive;
+
+ [Resolved]
+ private OverlayColourProvider overlayColourProvider { get; set; } = null!;
+
+ public LatencyCursorContainer(BindableBool isActive)
+ {
+ this.isActive = isActive;
+ Masking = true;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ InternalChild = cursor = new Circle
+ {
+ Size = new Vector2(40),
+ Origin = Anchor.Centre,
+ Colour = overlayColourProvider.Colour2,
+ };
+
+ inputManager = GetContainingInputManager();
+ }
+
+ protected override bool OnHover(HoverEvent e) => false;
+
+ protected override void Update()
+ {
+ cursor.Colour = inputManager.CurrentState.Mouse.IsPressed(MouseButton.Left) ? overlayColourProvider.Content1 : overlayColourProvider.Colour2;
+
+ if (isActive.Value)
+ {
+ cursor.Position = ToLocalSpace(inputManager.CurrentState.Mouse.Position);
+ cursor.Alpha = 1;
+ }
+ else
+ {
+ cursor.Alpha = 0;
+ }
+
+ base.Update();
+ }
+ }
+ }
+}
diff --git a/osu.Game/Screens/Utility/LatencyCertifierScreen.cs b/osu.Game/Screens/Utility/LatencyCertifierScreen.cs
new file mode 100644
index 0000000000..0a9d98450f
--- /dev/null
+++ b/osu.Game/Screens/Utility/LatencyCertifierScreen.cs
@@ -0,0 +1,461 @@
+// 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.Diagnostics;
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Configuration;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Colour;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Effects;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Input.Events;
+using osu.Framework.Platform;
+using osu.Framework.Platform.Windows;
+using osu.Framework.Screens;
+using osu.Framework.Utils;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Overlays;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Screens.Utility
+{
+ public class LatencyCertifierScreen : OsuScreen
+ {
+ private FrameSync previousFrameSyncMode;
+ private double previousActiveHz;
+
+ private readonly OsuTextFlowContainer statusText;
+
+ public override bool HideOverlaysOnEnter => true;
+
+ public override bool CursorVisible => mainArea.Count == 0;
+
+ public override float BackgroundParallaxAmount => 0;
+
+ private readonly OsuTextFlowContainer explanatoryText;
+
+ private readonly Container mainArea;
+
+ private readonly Container resultsArea;
+
+ ///
+ /// The rate at which the game host should attempt to run.
+ ///
+ private const int target_host_update_frames = 4000;
+
+ [Cached]
+ private readonly OverlayColourProvider overlayColourProvider = new OverlayColourProvider(OverlayColourScheme.Orange);
+
+ [Resolved]
+ private OsuColour colours { get; set; } = null!;
+
+ [Resolved]
+ private FrameworkConfigManager config { get; set; } = null!;
+
+ private const int rounds_to_complete = 5;
+
+ private const int rounds_to_complete_certified = 20;
+
+ ///
+ /// Whether we are now in certification mode and decreasing difficulty.
+ ///
+ private bool isCertifying;
+
+ private int totalRoundForNextResultsScreen => isCertifying ? rounds_to_complete_certified : rounds_to_complete;
+
+ private int attemptsAtCurrentDifficulty;
+ private int correctAtCurrentDifficulty;
+
+ public int DifficultyLevel { get; private set; } = 1;
+
+ private double lastPoll;
+ private int pollingMax;
+
+ [Resolved]
+ private GameHost host { get; set; } = null!;
+
+ public LatencyCertifierScreen()
+ {
+ InternalChildren = new Drawable[]
+ {
+ new Box
+ {
+ Colour = overlayColourProvider.Background6,
+ RelativeSizeAxes = Axes.Both,
+ },
+ mainArea = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ },
+ // Make sure the edge between the two comparisons can't be used to ascertain latency.
+ new Box
+ {
+ Name = "separator",
+ Colour = ColourInfo.GradientHorizontal(overlayColourProvider.Background6, overlayColourProvider.Background6.Opacity(0)),
+ Width = 100,
+ RelativeSizeAxes = Axes.Y,
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.TopLeft,
+ },
+ new Box
+ {
+ Name = "separator",
+ Colour = ColourInfo.GradientHorizontal(overlayColourProvider.Background6.Opacity(0), overlayColourProvider.Background6),
+ Width = 100,
+ RelativeSizeAxes = Axes.Y,
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.TopRight,
+ },
+ explanatoryText = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 20))
+ {
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.BottomCentre,
+ TextAnchor = Anchor.TopCentre,
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Text = @"Welcome to the latency certifier!
+Use the arrow keys, Z/X/J/K to move the square.
+Use the Tab key to change focus.
+Do whatever you need to try and perceive the difference in latency, then choose your best side.
+",
+ },
+ resultsArea = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ },
+ statusText = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 40))
+ {
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.TopCentre,
+ TextAnchor = Anchor.TopCentre,
+ Y = 150,
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ },
+ };
+ }
+
+ protected override bool OnMouseMove(MouseMoveEvent e)
+ {
+ if (lastPoll > 0)
+ pollingMax = (int)Math.Max(pollingMax, 1000 / (Clock.CurrentTime - lastPoll));
+ lastPoll = Clock.CurrentTime;
+ return base.OnMouseMove(e);
+ }
+
+ public override void OnEntering(ScreenTransitionEvent e)
+ {
+ base.OnEntering(e);
+
+ previousFrameSyncMode = config.Get(FrameworkSetting.FrameSync);
+ previousActiveHz = host.UpdateThread.ActiveHz;
+ config.SetValue(FrameworkSetting.FrameSync, FrameSync.Unlimited);
+ host.UpdateThread.ActiveHz = target_host_update_frames;
+ host.AllowBenchmarkUnlimitedFrames = true;
+ }
+
+ public override bool OnExiting(ScreenExitEvent e)
+ {
+ host.AllowBenchmarkUnlimitedFrames = false;
+ config.SetValue(FrameworkSetting.FrameSync, previousFrameSyncMode);
+ host.UpdateThread.ActiveHz = previousActiveHz;
+ return base.OnExiting(e);
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ loadNextRound();
+ }
+
+ protected override bool OnKeyDown(KeyDownEvent e)
+ {
+ switch (e.Key)
+ {
+ case Key.Tab:
+ var firstArea = mainArea.FirstOrDefault(a => !a.IsActiveArea.Value);
+ if (firstArea != null)
+ firstArea.IsActiveArea.Value = true;
+ return true;
+ }
+
+ return base.OnKeyDown(e);
+ }
+
+ private void showResults()
+ {
+ mainArea.Clear();
+
+ var displayMode = host.Window?.CurrentDisplayMode.Value;
+
+ string exclusive = "unknown";
+
+ if (host.Window is WindowsWindow windowsWindow)
+ exclusive = windowsWindow.FullscreenCapability.ToString();
+
+ statusText.Clear();
+
+ float successRate = (float)correctAtCurrentDifficulty / attemptsAtCurrentDifficulty;
+ bool isPass = successRate == 1;
+
+ statusText.AddParagraph($"You scored {correctAtCurrentDifficulty} out of {attemptsAtCurrentDifficulty} ({successRate:0%})!", cp => cp.Colour = isPass ? colours.Green : colours.Red);
+ statusText.AddParagraph($"Level {DifficultyLevel} ({mapDifficultyToTargetFrameRate(DifficultyLevel):N0} Hz)",
+ cp => cp.Font = OsuFont.Default.With(size: 24));
+
+ statusText.AddParagraph(string.Empty);
+ statusText.AddParagraph(string.Empty);
+ statusText.AddIcon(isPass ? FontAwesome.Regular.CheckCircle : FontAwesome.Regular.TimesCircle, cp => cp.Colour = isPass ? colours.Green : colours.Red);
+ statusText.AddParagraph(string.Empty);
+
+ if (!isPass && DifficultyLevel > 1)
+ {
+ statusText.AddParagraph("To complete certification, the difficulty level will now decrease until you can get 20 rounds correct in a row!",
+ cp => cp.Font = OsuFont.Default.With(size: 24, weight: FontWeight.SemiBold));
+ statusText.AddParagraph(string.Empty);
+ }
+
+ statusText.AddParagraph($"Polling: {pollingMax} Hz Monitor: {displayMode?.RefreshRate ?? 0:N0} Hz Exclusive: {exclusive}",
+ cp => cp.Font = OsuFont.Default.With(size: 15, weight: FontWeight.SemiBold));
+
+ statusText.AddParagraph($"Input: {host.InputThread.Clock.FramesPerSecond} Hz "
+ + $"Update: {host.UpdateThread.Clock.FramesPerSecond} Hz "
+ + $"Draw: {host.DrawThread.Clock.FramesPerSecond} Hz"
+ , cp => cp.Font = OsuFont.Default.With(size: 15, weight: FontWeight.SemiBold));
+
+ if (isCertifying && isPass)
+ {
+ showCertifiedScreen();
+ return;
+ }
+
+ string cannotIncreaseReason = string.Empty;
+
+ if (mapDifficultyToTargetFrameRate(DifficultyLevel + 1) > target_host_update_frames)
+ cannotIncreaseReason = "You've reached the maximum level.";
+ else if (mapDifficultyToTargetFrameRate(DifficultyLevel + 1) > Clock.FramesPerSecond)
+ cannotIncreaseReason = "Game is not running fast enough to test this level";
+
+ FillFlowContainer buttonFlow;
+
+ resultsArea.Add(buttonFlow = new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Anchor = Anchor.BottomLeft,
+ Origin = Anchor.BottomLeft,
+ Spacing = new Vector2(20),
+ Padding = new MarginPadding(20),
+ });
+
+ if (isPass)
+ {
+ buttonFlow.Add(new ButtonWithKeyBind(Key.Enter)
+ {
+ Text = "Continue to next level",
+ BackgroundColour = colours.Green,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Action = () => changeDifficulty(DifficultyLevel + 1),
+ Enabled = { Value = string.IsNullOrEmpty(cannotIncreaseReason) },
+ TooltipText = cannotIncreaseReason
+ });
+ }
+ else
+ {
+ if (DifficultyLevel == 1)
+ {
+ buttonFlow.Add(new ButtonWithKeyBind(Key.Enter)
+ {
+ Text = "Retry",
+ TooltipText = "Are you even trying..?",
+ BackgroundColour = colours.Pink2,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Action = () =>
+ {
+ isCertifying = false;
+ changeDifficulty(1);
+ },
+ });
+ }
+ else
+ {
+ buttonFlow.Add(new ButtonWithKeyBind(Key.Enter)
+ {
+ Text = "Begin certification at last level",
+ BackgroundColour = colours.Yellow,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Action = () =>
+ {
+ isCertifying = true;
+ changeDifficulty(DifficultyLevel - 1);
+ },
+ TooltipText = isPass ? $"Chain {rounds_to_complete_certified} rounds to confirm your perception!" : "You've reached your limits. Go to the previous level to complete certification!",
+ });
+ }
+ }
+ }
+
+ private void showCertifiedScreen()
+ {
+ Drawable background;
+ Drawable certifiedText;
+
+ resultsArea.AddRange(new[]
+ {
+ background = new Box
+ {
+ Colour = overlayColourProvider.Background4,
+ RelativeSizeAxes = Axes.Both,
+ },
+ (certifiedText = new OsuSpriteText
+ {
+ Alpha = 0,
+ Font = OsuFont.TorusAlternate.With(size: 80, weight: FontWeight.Bold),
+ Text = "Certified!",
+ Blending = BlendingParameters.Additive,
+ }).WithEffect(new GlowEffect
+ {
+ Colour = overlayColourProvider.Colour1,
+ PadExtent = true
+ }).With(e =>
+ {
+ e.Anchor = Anchor.Centre;
+ e.Origin = Anchor.Centre;
+ }),
+ new OsuSpriteText
+ {
+ Text = $"You should use a frame limiter with update rate of {mapDifficultyToTargetFrameRate(DifficultyLevel + 1)} Hz (or fps) for best results!",
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Font = OsuFont.Torus.With(size: 24, weight: FontWeight.SemiBold),
+ Y = 80,
+ }
+ });
+
+ background.FadeInFromZero(1000, Easing.OutQuint);
+
+ certifiedText.FadeInFromZero(500, Easing.InQuint);
+
+ certifiedText
+ .ScaleTo(10)
+ .ScaleTo(1, 600, Easing.InQuad)
+ .Then()
+ .ScaleTo(1.05f, 10000, Easing.OutQuint);
+ }
+
+ private void changeDifficulty(int difficulty)
+ {
+ Debug.Assert(difficulty > 0);
+
+ resultsArea.Clear();
+
+ correctAtCurrentDifficulty = 0;
+ attemptsAtCurrentDifficulty = 0;
+
+ pollingMax = 0;
+ lastPoll = 0;
+
+ DifficultyLevel = difficulty;
+
+ loadNextRound();
+ }
+
+ private void loadNextRound()
+ {
+ attemptsAtCurrentDifficulty++;
+ statusText.Text = $"Level {DifficultyLevel}\nRound {attemptsAtCurrentDifficulty} of {totalRoundForNextResultsScreen}";
+
+ mainArea.Clear();
+
+ int betterSide = RNG.Next(0, 2);
+
+ mainArea.AddRange(new[]
+ {
+ new LatencyArea(Key.Number1, betterSide == 1 ? mapDifficultyToTargetFrameRate(DifficultyLevel) : (int?)null)
+ {
+ Width = 0.5f,
+ IsActiveArea = { Value = true },
+ ReportUserBest = () => recordResult(betterSide == 0),
+ },
+ new LatencyArea(Key.Number2, betterSide == 0 ? mapDifficultyToTargetFrameRate(DifficultyLevel) : (int?)null)
+ {
+ Width = 0.5f,
+ Anchor = Anchor.TopRight,
+ Origin = Anchor.TopRight,
+ ReportUserBest = () => recordResult(betterSide == 1)
+ }
+ });
+
+ foreach (var area in mainArea)
+ {
+ area.IsActiveArea.BindValueChanged(active =>
+ {
+ if (active.NewValue)
+ mainArea.Children.First(a => a != area).IsActiveArea.Value = false;
+ });
+ }
+ }
+
+ private void recordResult(bool correct)
+ {
+ // Fading this out will improve the frame rate after the first round due to less text on screen.
+ explanatoryText.FadeOut(500, Easing.OutQuint);
+
+ if (correct)
+ correctAtCurrentDifficulty++;
+
+ if (attemptsAtCurrentDifficulty < totalRoundForNextResultsScreen)
+ loadNextRound();
+ else
+ showResults();
+ }
+
+ private static int mapDifficultyToTargetFrameRate(int difficulty)
+ {
+ switch (difficulty)
+ {
+ case 1:
+ return 15;
+
+ case 2:
+ return 30;
+
+ case 3:
+ return 45;
+
+ case 4:
+ return 60;
+
+ case 5:
+ return 120;
+
+ case 6:
+ return 240;
+
+ case 7:
+ return 480;
+
+ case 8:
+ return 720;
+
+ case 9:
+ return 960;
+
+ default:
+ return 1000 + ((difficulty - 10) * 500);
+ }
+ }
+ }
+}
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 63b8cf4cb5..b6218c5950 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -36,7 +36,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index a0fafa635b..ba57aba01b 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -61,7 +61,7 @@
-
+
@@ -84,7 +84,7 @@
-
+