// 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.Bindables; 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!; public readonly Bindable VisualMode = new Bindable(); 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/F/J to control the display. Use the Tab key to change focus. Change display modes with Space. 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.Space: VisualMode.Value = (LatencyVisualMode)(((int)VisualMode.Value + 1) % 3); return true; 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, VisualMode = { BindTarget = VisualMode }, IsActiveArea = { Value = true }, ReportUserBest = () => recordResult(betterSide == 0), }, new LatencyArea(Key.Number2, betterSide == 0 ? mapDifficultyToTargetFrameRate(DifficultyLevel) : (int?)null) { Width = 0.5f, VisualMode = { BindTarget = VisualMode }, 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); } } } }