diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs new file mode 100644 index 000000000000..5ca09b34aae1 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Screens; +using osu.Game.Overlays.FirstRunSetup; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneFirstRunScreenUIScale : OsuManualInputManagerTestScene + { + public TestSceneFirstRunScreenUIScale() + { + AddStep("load screen", () => + { + Child = new ScreenStack(new ScreenUIScale()); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs new file mode 100644 index 000000000000..efce4f350b96 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs @@ -0,0 +1,193 @@ +// 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.Collections.Generic; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Overlays.FirstRunSetup; +using osu.Game.Overlays.Notifications; +using osu.Game.Screens; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneFirstRunSetupOverlay : OsuManualInputManagerTestScene + { + private FirstRunSetupOverlay overlay; + + private readonly Mock performer = new Mock(); + + private readonly Mock notificationOverlay = new Mock(); + + private Notification lastNotification; + + protected OsuConfigManager LocalConfig; + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(LocalConfig = new OsuConfigManager(LocalStorage)); + Dependencies.CacheAs(performer.Object); + Dependencies.CacheAs(notificationOverlay.Object); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("setup dependencies", () => + { + performer.Reset(); + notificationOverlay.Reset(); + + performer.Setup(g => g.PerformFromScreen(It.IsAny>(), It.IsAny>())) + .Callback((Action action, IEnumerable types) => action(null)); + + notificationOverlay.Setup(n => n.Post(It.IsAny())) + .Callback((Notification n) => lastNotification = n); + }); + + AddStep("add overlay", () => + { + Child = overlay = new FirstRunSetupOverlay + { + State = { Value = Visibility.Visible } + }; + }); + } + + [Test] + public void TestDoesntOpenOnSecondRun() + { + AddStep("set first run", () => LocalConfig.SetValue(OsuSetting.ShowFirstRunSetup, true)); + + AddUntilStep("step through", () => + { + if (overlay.CurrentScreen?.IsLoaded != false) overlay.NextButton.TriggerClick(); + return overlay.State.Value == Visibility.Hidden; + }); + + AddAssert("first run false", () => !LocalConfig.Get(OsuSetting.ShowFirstRunSetup)); + + AddStep("add overlay", () => + { + Child = overlay = new FirstRunSetupOverlay(); + }); + + AddWaitStep("wait some", 5); + + AddAssert("overlay didn't show", () => overlay.State.Value == Visibility.Hidden); + } + + [TestCase(false)] + [TestCase(true)] + public void TestOverlayRunsToFinish(bool keyboard) + { + AddUntilStep("step through", () => + { + if (overlay.CurrentScreen?.IsLoaded != false) + { + if (keyboard) + InputManager.Key(Key.Enter); + else + overlay.NextButton.TriggerClick(); + } + + return overlay.State.Value == Visibility.Hidden; + }); + + AddUntilStep("wait for screens removed", () => !overlay.ChildrenOfType().Any()); + + AddStep("no notifications", () => notificationOverlay.VerifyNoOtherCalls()); + + AddStep("display again on demand", () => overlay.Show()); + + AddUntilStep("back at start", () => overlay.CurrentScreen is ScreenWelcome); + } + + [TestCase(false)] + [TestCase(true)] + public void TestBackButton(bool keyboard) + { + AddAssert("back button disabled", () => !overlay.BackButton.Enabled.Value); + + AddUntilStep("step to last", () => + { + var nextButton = overlay.NextButton; + + if (overlay.CurrentScreen?.IsLoaded != false) + nextButton.TriggerClick(); + + return nextButton.Text == CommonStrings.Finish; + }); + + AddUntilStep("step back to start", () => + { + if (overlay.CurrentScreen?.IsLoaded != false) + { + if (keyboard) + InputManager.Key(Key.Escape); + else + overlay.BackButton.TriggerClick(); + } + + return overlay.CurrentScreen is ScreenWelcome; + }); + + AddAssert("back button disabled", () => !overlay.BackButton.Enabled.Value); + + if (keyboard) + { + AddStep("exit via keyboard", () => InputManager.Key(Key.Escape)); + AddAssert("overlay dismissed", () => overlay.State.Value == Visibility.Hidden); + } + } + + [Test] + public void TestClickAwayToExit() + { + AddStep("click inside content", () => + { + InputManager.MoveMouseTo(overlay.ScreenSpaceDrawQuad.Centre); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("overlay not dismissed", () => overlay.State.Value == Visibility.Visible); + + AddStep("click outside content", () => + { + InputManager.MoveMouseTo(overlay.ScreenSpaceDrawQuad.TopLeft - new Vector2(1)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("overlay dismissed", () => overlay.State.Value == Visibility.Hidden); + } + + [Test] + public void TestResumeViaNotification() + { + AddStep("step to next", () => overlay.NextButton.TriggerClick()); + + AddAssert("is at known screen", () => overlay.CurrentScreen is ScreenUIScale); + + AddStep("hide", () => overlay.Hide()); + AddAssert("overlay hidden", () => overlay.State.Value == Visibility.Hidden); + + AddStep("notification arrived", () => notificationOverlay.Verify(n => n.Post(It.IsAny()), Times.Once)); + + AddStep("run notification action", () => lastNotification.Activated()); + + AddAssert("overlay shown", () => overlay.State.Value == Visibility.Visible); + AddAssert("is resumed", () => overlay.CurrentScreen is ScreenUIScale); + } + } +} diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 9aacb5068430..2c04b0364628 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -134,6 +134,8 @@ protected override void InitialiseDefaults() SetDefault(OsuSetting.Version, string.Empty); + SetDefault(OsuSetting.ShowFirstRunSetup, true); + SetDefault(OsuSetting.ScreenshotFormat, ScreenshotFormat.Jpg); SetDefault(OsuSetting.ScreenshotCaptureMenuCursor, false); @@ -308,6 +310,7 @@ public enum OsuSetting BeatmapListingCardSize, ToolbarClockDisplayMode, Version, + ShowFirstRunSetup, ShowConvertedBeatmaps, Skin, ScreenshotFormat, diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index 89432595f399..11bfd80ec115 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -79,12 +79,12 @@ public ScalingContainer(ScalingMode? targetMode = null) }; } - private class ScalingDrawSizePreservingFillContainer : DrawSizePreservingFillContainer + public class ScalingDrawSizePreservingFillContainer : DrawSizePreservingFillContainer { private readonly bool applyUIScale; private Bindable uiScale; - private float currentScale = 1; + protected float CurrentScale { get; private set; } = 1; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; @@ -99,14 +99,14 @@ private void load(OsuConfigManager osuConfig) if (applyUIScale) { uiScale = osuConfig.GetBindable(OsuSetting.UIScale); - uiScale.BindValueChanged(args => this.TransformTo(nameof(currentScale), args.NewValue, duration, Easing.OutQuart), true); + uiScale.BindValueChanged(args => this.TransformTo(nameof(CurrentScale), args.NewValue, duration, Easing.OutQuart), true); } } protected override void Update() { - Scale = new Vector2(currentScale); - Size = new Vector2(1 / currentScale); + Scale = new Vector2(CurrentScale); + Size = new Vector2(1 / CurrentScale); base.Update(); } diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index b717bb83dd0b..6da2e4d2722f 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -9,6 +9,16 @@ public static class CommonStrings { private const string prefix = @"osu.Game.Resources.Localisation.Common"; + /// + /// "Back" + /// + public static LocalisableString Back => new TranslatableString(getKey(@"back"), @"Back"); + + /// + /// "Finish" + /// + public static LocalisableString Finish => new TranslatableString(getKey(@"finish"), @"Finish"); + /// /// "Enabled" /// diff --git a/osu.Game/Localisation/FirstRunSetupOverlayStrings.cs b/osu.Game/Localisation/FirstRunSetupOverlayStrings.cs new file mode 100644 index 000000000000..001de93c1679 --- /dev/null +++ b/osu.Game/Localisation/FirstRunSetupOverlayStrings.cs @@ -0,0 +1,58 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class FirstRunSetupOverlayStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.FirstRunSetupOverlay"; + + /// + /// "Get started" + /// + public static LocalisableString GetStarted => new TranslatableString(getKey(@"get_started"), @"Get started"); + + /// + /// "Click to resume first-run setup at any point" + /// + public static LocalisableString ClickToResumeFirstRunSetupAtAnyPoint => new TranslatableString(getKey(@"click_to_resume_first_run_setup_at_any_point"), @"Click to resume first-run setup at any point"); + + /// + /// "First-run setup" + /// + public static LocalisableString FirstRunSetupTitle => new TranslatableString(getKey(@"first_run_setup_title"), @"First-run setup"); + + /// + /// "Set up osu! to suit you" + /// + public static LocalisableString FirstRunSetupDescription => new TranslatableString(getKey(@"first_run_setup_description"), @"Set up osu! to suit you"); + + /// + /// "Welcome" + /// + public static LocalisableString WelcomeTitle => new TranslatableString(getKey(@"welcome_title"), @"Welcome"); + + /// + /// "Welcome to the first-run setup guide! + /// + /// osu! is a very configurable game, and diving straight into the settings can sometimes be overwhelming. This guide will help you get the important choices out of the way to ensure a great first experience!" + /// + public static LocalisableString WelcomeDescription => new TranslatableString(getKey(@"welcome_description"), @"Welcome to the first-run setup guide! + +osu! is a very configurable game, and diving straight into the settings can sometimes be overwhelming. This guide will help you get the important choices out of the way to ensure a great first experience!"); + + /// + /// "The size of the osu! user interface can be adjusted to your liking." + /// + public static LocalisableString UIScaleDescription => new TranslatableString(getKey(@"ui_scale_description"), @"The size of the osu! user interface can be adjusted to your liking."); + + /// + /// "Next ({0})" + /// + public static LocalisableString Next(LocalisableString nextStepDescription) => new TranslatableString(getKey(@"next"), @"Next ({0})", nextStepDescription); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index c65e6c77f4e0..2aa91f5245f4 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -59,6 +59,11 @@ public static class GeneralSettingsStrings /// public static LocalisableString ChangeFolderLocation => new TranslatableString(getKey(@"change_folder_location"), @"Change folder location..."); + /// + /// "Run setup wizard" + /// + public static LocalisableString RunSetupWizard => new TranslatableString(getKey(@"run_setup_wizard"), @"Run setup wizard"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index ba87c69d8f27..e9fe8c43de0d 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -149,6 +149,8 @@ public class OsuGame : OsuGameBase, IKeyBindingHandler, ILocalUser protected SettingsOverlay Settings; + private FirstRunSetupOverlay firstRunOverlay; + private VolumeOverlay volume; private OsuLogo osuLogo; @@ -799,6 +801,7 @@ protected override void LoadComplete() loadComponentSingleFile(CreateUpdateManager(), Add, true); // overlay elements + loadComponentSingleFile(firstRunOverlay = new FirstRunSetupOverlay(), overlayContent.Add, true); loadComponentSingleFile(new ManageCollectionsDialog(), overlayContent.Add, true); loadComponentSingleFile(beatmapListing = new BeatmapListingOverlay(), overlayContent.Add, true); loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true); @@ -849,7 +852,7 @@ void updateChatPollRate() Add(new MusicKeyBindingHandler()); // side overlays which cancel each other. - var singleDisplaySideOverlays = new OverlayContainer[] { Settings, Notifications }; + var singleDisplaySideOverlays = new OverlayContainer[] { Settings, Notifications, firstRunOverlay }; foreach (var overlay in singleDisplaySideOverlays) { @@ -874,7 +877,7 @@ void updateChatPollRate() } // ensure only one of these overlays are open at once. - var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay, wikiOverlay }; + var singleDisplayOverlays = new OverlayContainer[] { firstRunOverlay, chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay, wikiOverlay }; foreach (var overlay in singleDisplayOverlays) { diff --git a/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs b/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs new file mode 100644 index 000000000000..eb4b97069cb0 --- /dev/null +++ b/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Overlays.FirstRunSetup +{ + public abstract class FirstRunSetupScreen : Screen + { + private const float offset = 100; + + protected FillFlowContainer Content { get; private set; } + + protected FirstRunSetupScreen() + { + InternalChildren = new Drawable[] + { + new OsuScrollContainer(Direction.Vertical) + { + RelativeSizeAxes = Axes.Both, + Child = Content = new FillFlowContainer + { + Spacing = new Vector2(20), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }, + } + }; + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + this + .FadeInFromZero(500) + .MoveToX(offset) + .MoveToX(0, 500, Easing.OutQuint); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + this + .FadeInFromZero(500) + .MoveToX(0, 500, Easing.OutQuint); + } + + public override bool OnExiting(ScreenExitEvent e) + { + this + .FadeOut(100) + .MoveToX(offset, 500, Easing.OutQuint); + + return base.OnExiting(e); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + this + .FadeOut(100) + .MoveToX(-offset, 500, Easing.OutQuint); + + base.OnSuspending(e); + } + } +} diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs new file mode 100644 index 000000000000..d9a612ea26b6 --- /dev/null +++ b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs @@ -0,0 +1,182 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets; +using osu.Game.Screens; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Select; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Overlays.FirstRunSetup +{ + public class ScreenUIScale : FirstRunSetupScreen + { + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Content.Children = new Drawable[] + { + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 24)) + { + Text = FirstRunSetupOverlayStrings.UIScaleDescription, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }, + new SettingsSlider + { + LabelText = GraphicsSettingsStrings.UIScaling, + Current = config.GetBindable(OsuSetting.UIScale), + KeyboardStep = 0.01f, + }, + new InverseScalingDrawSizePreservingFillContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.None, + Size = new Vector2(960, 960 / 16f * 9 / 2), + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new SampleScreenContainer(new PinnedMainMenu()), + new SampleScreenContainer(new PlaySongSelect()), + }, + // TODO: add more screens here in the future (gameplay / results) + // requires a bit more consideration to isolate their behaviour from the "parent" game. + } + } + } + } + }; + } + + private class InverseScalingDrawSizePreservingFillContainer : ScalingContainer.ScalingDrawSizePreservingFillContainer + { + private Vector2 initialSize; + + public InverseScalingDrawSizePreservingFillContainer() + : base(true) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + initialSize = Size; + } + + protected override void Update() + { + Size = initialSize / CurrentScale; + } + } + + private class PinnedMainMenu : MainMenu + { + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + Buttons.ReturnToTopOnIdle = false; + Buttons.State = ButtonSystemState.TopLevel; + } + } + + private class UIScaleSlider : OsuSliderBar + { + public override LocalisableString TooltipText => base.TooltipText + "x"; + } + + private class SampleScreenContainer : CompositeDrawable + { + // Minimal isolation from main game. + + [Cached] + [Cached(typeof(IBindable))] + protected readonly Bindable Ruleset = new Bindable(); + + [Cached] + [Cached(typeof(IBindable))] + protected Bindable Beatmap { get; private set; } = new Bindable(); + + public override bool HandlePositionalInput => false; + public override bool HandleNonPositionalInput => false; + public override bool PropagatePositionalInputSubTree => false; + public override bool PropagateNonPositionalInputSubTree => false; + + [BackgroundDependencyLoader] + private void load(AudioManager audio, TextureStore textures, RulesetStore rulesets) + { + Beatmap.Value = new DummyWorkingBeatmap(audio, textures); + Beatmap.Value.LoadTrack(); + + Ruleset.Value = rulesets.AvailableRulesets.First(); + } + + public SampleScreenContainer(Screen screen) + { + OsuScreenStack stack; + RelativeSizeAxes = Axes.Both; + + OsuLogo logo; + + Padding = new MarginPadding(5); + + InternalChildren = new Drawable[] + { + new DependencyProvidingContainer + { + CachedDependencies = new (Type, object)[] + { + (typeof(OsuLogo), logo = new OsuLogo + { + RelativePositionAxes = Axes.Both, + Position = new Vector2(0.5f), + }) + }, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new ScalingContainer.ScalingDrawSizePreservingFillContainer(true) + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + stack = new OsuScreenStack(), + logo + }, + }, + } + }, + }; + + stack.Push(screen); + } + } + } +} diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs new file mode 100644 index 000000000000..39da180f4057 --- /dev/null +++ b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.FirstRunSetup +{ + public class ScreenWelcome : FirstRunSetupScreen + { + public ScreenWelcome() + { + Content.Children = new Drawable[] + { + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 20)) + { + Text = FirstRunSetupOverlayStrings.WelcomeDescription, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }, + }; + } + } +} diff --git a/osu.Game/Overlays/FirstRunSetupOverlay.cs b/osu.Game/Overlays/FirstRunSetupOverlay.cs new file mode 100644 index 000000000000..a12fec450758 --- /dev/null +++ b/osu.Game/Overlays/FirstRunSetupOverlay.cs @@ -0,0 +1,398 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +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.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Framework.Screens; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Localisation; +using osu.Game.Overlays.FirstRunSetup; +using osu.Game.Overlays.Notifications; +using osu.Game.Screens; +using osu.Game.Screens.Menu; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays +{ + [Cached] + public class FirstRunSetupOverlay : OsuFocusedOverlayContainer + { + protected override bool StartHidden => true; + + [Resolved] + private IPerformFromScreenRunner performer { get; set; } = null!; + + [Resolved] + private INotificationOverlay notificationOverlay { get; set; } = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private ScreenStack? stack; + + public PurpleTriangleButton NextButton = null!; + public DangerousTriangleButton BackButton = null!; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private readonly Bindable showFirstRunSetup = new Bindable(); + + private int? currentStepIndex; + + private const float scale_when_hidden = 0.9f; + + /// + /// The currently displayed screen, if any. + /// + public FirstRunSetupScreen? CurrentScreen => (FirstRunSetupScreen?)stack?.CurrentScreen; + + private readonly FirstRunStep[] steps = + { + new FirstRunStep(typeof(ScreenWelcome), FirstRunSetupOverlayStrings.WelcomeTitle), + new FirstRunStep(typeof(ScreenUIScale), GraphicsSettingsStrings.UIScaling), + }; + + private Container stackContainer = null!; + + private Bindable? overlayActivationMode; + + public FirstRunSetupOverlay() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = new Vector2(0.95f); + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 5, + Colour = Color4.Black.Opacity(0.2f), + }; + + Masking = true; + CornerRadius = 10; + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding(10), + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = FirstRunSetupOverlayStrings.FirstRunSetupTitle, + Font = OsuFont.Default.With(size: 32), + Colour = colourProvider.Content1, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new OsuTextFlowContainer + { + Text = FirstRunSetupOverlayStrings.FirstRunSetupDescription, + Colour = colourProvider.Content2, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + }, + } + }, + } + }, + }, + new Drawable[] + { + stackContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(20), + }, + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(20) + { + Top = 0 // provided by the stack container above. + }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + BackButton = new DangerousTriangleButton + { + Width = 200, + Text = CommonStrings.Back, + Action = showPreviousStep, + Enabled = { Value = false }, + }, + Empty(), + NextButton = new PurpleTriangleButton + { + RelativeSizeAxes = Axes.X, + Width = 1, + Text = FirstRunSetupOverlayStrings.GetStarted, + Action = showNextStep + } + }, + } + }, + } + } + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + config.BindWith(OsuSetting.ShowFirstRunSetup, showFirstRunSetup); + + if (showFirstRunSetup.Value) Show(); + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (!e.Repeat) + { + switch (e.Action) + { + case GlobalAction.Select: + NextButton.TriggerClick(); + return true; + + case GlobalAction.Back: + if (BackButton.Enabled.Value) + { + BackButton.TriggerClick(); + return true; + } + + // If back button is disabled, we are at the first step. + // The base call will handle dismissal of the overlay. + break; + } + } + + return base.OnPressed(e); + } + + public override void Show() + { + // if we are valid for display, only do so after reaching the main menu. + performer.PerformFromScreen(screen => + { + MainMenu menu = (MainMenu)screen; + + // Eventually I'd like to replace this with a better method that doesn't access the screen. + // Either this dialog would be converted to its own screen, or at very least be "hosted" by a screen pushed to the main menu. + // Alternatively, another method of disabling notifications could be added to `INotificationOverlay`. + if (menu != null) + { + overlayActivationMode = menu.OverlayActivationMode.GetBoundCopy(); + overlayActivationMode.Value = OverlayActivation.UserTriggered; + } + + base.Show(); + }, new[] { typeof(MainMenu) }); + } + + protected override void PopIn() + { + base.PopIn(); + + this.ScaleTo(scale_when_hidden) + .ScaleTo(1, 400, Easing.OutElasticHalf); + + this.FadeIn(400, Easing.OutQuint); + + if (currentStepIndex == null) + showFirstStep(); + } + + protected override void PopOut() + { + if (overlayActivationMode != null) + { + // If this is non-null we are guaranteed to have come from the main menu. + overlayActivationMode.Value = OverlayActivation.All; + overlayActivationMode = null; + } + + if (currentStepIndex != null) + { + notificationOverlay.Post(new SimpleNotification + { + Text = FirstRunSetupOverlayStrings.ClickToResumeFirstRunSetupAtAnyPoint, + Icon = FontAwesome.Solid.Redo, + Activated = () => + { + Show(); + return true; + }, + }); + } + else + { + stack?.FadeOut(100) + .Expire(); + } + + base.PopOut(); + + this.ScaleTo(0.96f, 400, Easing.OutQuint); + this.FadeOut(200, Easing.OutQuint); + } + + private void showFirstStep() + { + Debug.Assert(currentStepIndex == null); + + stackContainer.Child = stack = new ScreenStack + { + RelativeSizeAxes = Axes.Both, + }; + + currentStepIndex = -1; + showNextStep(); + } + + private void showPreviousStep() + { + if (currentStepIndex == 0) + return; + + Debug.Assert(stack != null); + + stack.CurrentScreen.Exit(); + currentStepIndex--; + + updateButtons(); + } + + private void showNextStep() + { + Debug.Assert(currentStepIndex != null); + Debug.Assert(stack != null); + + currentStepIndex++; + + if (currentStepIndex < steps.Length) + { + stack.Push((Screen)Activator.CreateInstance(steps[currentStepIndex.Value].ScreenType)); + } + else + { + showFirstRunSetup.Value = false; + currentStepIndex = null; + Hide(); + } + + updateButtons(); + } + + private void updateButtons() + { + BackButton.Enabled.Value = currentStepIndex > 0; + NextButton.Enabled.Value = currentStepIndex != null; + + if (currentStepIndex != null) + { + NextButton.Text = currentStepIndex + 1 < steps.Length + ? FirstRunSetupOverlayStrings.Next(steps[currentStepIndex.Value + 1].Description) + : CommonStrings.Finish; + } + } + + private class FirstRunStep + { + public readonly Type ScreenType; + public readonly LocalisableString Description; + + public FirstRunStep(Type screenType, LocalisableString description) + { + ScreenType = screenType; + Description = description; + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs index 87e9f3483375..ced31167287d 100644 --- a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GeneralSection.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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -11,6 +12,9 @@ namespace osu.Game.Overlays.Settings.Sections { public class GeneralSection : SettingsSection { + [Resolved(CanBeNull = true)] + private FirstRunSetupOverlay firstRunSetupOverlay { get; set; } + public override LocalisableString Header => GeneralSettingsStrings.GeneralSectionHeader; public override Drawable CreateIcon() => new SpriteIcon @@ -22,6 +26,11 @@ public GeneralSection() { Children = new Drawable[] { + new SettingsButton + { + Text = GeneralSettingsStrings.RunSetupWizard, + Action = () => firstRunSetupOverlay?.Show(), + }, new LanguageSettings(), new UpdateSettings(), }; diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index 2d18dce6dabd..885f4903b065 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -87,6 +87,8 @@ public void SetOsuLogo(OsuLogo logo) private readonly LogoTrackingContainer logoTrackingContainer; + public bool ReturnToTopOnIdle { get; set; } = true; + public ButtonSystem() { RelativeSizeAxes = Axes.Both; @@ -100,7 +102,8 @@ public ButtonSystem() buttonArea.AddRange(new Drawable[] { new MainMenuButton(ButtonSystemStrings.Settings, string.Empty, FontAwesome.Solid.Cog, new Color4(85, 85, 85, 255), () => OnSettings?.Invoke(), -WEDGE_WIDTH, Key.O), - backButton = new MainMenuButton(ButtonSystemStrings.Back, @"button-back-select", OsuIcon.LeftCircle, new Color4(51, 58, 94, 255), () => State = ButtonSystemState.TopLevel, -WEDGE_WIDTH) + backButton = new MainMenuButton(ButtonSystemStrings.Back, @"button-back-select", OsuIcon.LeftCircle, new Color4(51, 58, 94, 255), () => State = ButtonSystemState.TopLevel, + -WEDGE_WIDTH) { VisibleState = ButtonSystemState.Play, }, @@ -127,9 +130,11 @@ private void load(AudioManager audio, IdleTracker idleTracker, GameHost host) buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Playlists, @"button-generic-select", OsuIcon.Charts, new Color4(94, 63, 186, 255), onPlaylists, 0, Key.L)); buttonsPlay.ForEach(b => b.VisibleState = ButtonSystemState.Play); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), () => State = ButtonSystemState.Play, WEDGE_WIDTH, Key.P)); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), () => State = ButtonSystemState.Play, WEDGE_WIDTH, + Key.P)); buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Edit, @"button-edit-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), () => OnEdit?.Invoke(), 0, Key.E)); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-direct-select", OsuIcon.ChevronDownCircle, new Color4(165, 204, 0, 255), () => OnBeatmapListing?.Invoke(), 0, Key.D)); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-direct-select", OsuIcon.ChevronDownCircle, new Color4(165, 204, 0, 255), () => OnBeatmapListing?.Invoke(), 0, + Key.D)); if (host.CanExit) buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Exit, string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), () => OnExit?.Invoke(), 0, Key.Q)); @@ -177,6 +182,9 @@ private void onPlaylists() private void updateIdleState(bool isIdle) { + if (!ReturnToTopOnIdle) + return; + if (isIdle && State != ButtonSystemState.Exit && State != ButtonSystemState.EnteringMode) State = ButtonSystemState.Initial; } diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index c9ce077695e7..4401ee93ecac 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -35,7 +35,7 @@ public class MainMenu : OsuScreen, IHandlePresentBeatmap, IKeyBindingHandler buttons == null || buttons.State == ButtonSystemState.Initial; + public override bool HideOverlaysOnEnter => Buttons == null || Buttons.State == ButtonSystemState.Initial; public override bool AllowBackButton => false; @@ -45,7 +45,7 @@ public class MainMenu : OsuScreen, IHandlePresentBeatmap, IKeyBindingHandler + Buttons.StateChanged += state => { switch (state) { @@ -140,8 +140,8 @@ private void load(BeatmapListingOverlay beatmapListing, SettingsOverlay settings } }; - buttons.OnSettings = () => settings?.ToggleVisibility(); - buttons.OnBeatmapListing = () => beatmapListing?.ToggleVisibility(); + Buttons.OnSettings = () => settings?.ToggleVisibility(); + Buttons.OnBeatmapListing = () => beatmapListing?.ToggleVisibility(); LoadComponentAsync(background = new BackgroundScreenDefault()); preloadSongSelect(); @@ -179,7 +179,7 @@ private Screen consumeSongSelect() public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); - buttons.FadeInFromZero(500); + Buttons.FadeInFromZero(500); if (e.Last is IntroScreen && musicController.TrackLoaded) { @@ -203,14 +203,14 @@ protected override void LogoArriving(OsuLogo logo, bool resuming) { base.LogoArriving(logo, resuming); - buttons.SetOsuLogo(logo); + Buttons.SetOsuLogo(logo); logo.FadeColour(Color4.White, 100, Easing.OutQuint); logo.FadeIn(100, Easing.OutQuint); if (resuming) { - buttons.State = ButtonSystemState.TopLevel; + Buttons.State = ButtonSystemState.TopLevel; this.FadeIn(FADE_IN_DURATION, Easing.OutQuint); buttonsContainer.MoveTo(new Vector2(0, 0), FADE_IN_DURATION, Easing.OutQuint); @@ -245,15 +245,15 @@ protected override void LogoSuspending(OsuLogo logo) var seq = logo.FadeOut(300, Easing.InSine) .ScaleTo(0.2f, 300, Easing.InSine); - seq.OnComplete(_ => buttons.SetOsuLogo(null)); - seq.OnAbort(_ => buttons.SetOsuLogo(null)); + seq.OnComplete(_ => Buttons.SetOsuLogo(null)); + seq.OnAbort(_ => Buttons.SetOsuLogo(null)); } public override void OnSuspending(ScreenTransitionEvent e) { base.OnSuspending(e); - buttons.State = ButtonSystemState.EnteringMode; + Buttons.State = ButtonSystemState.EnteringMode; this.FadeOut(FADE_OUT_DURATION, Easing.InSine); buttonsContainer.MoveTo(new Vector2(-800, 0), FADE_OUT_DURATION, Easing.InSine); @@ -285,7 +285,7 @@ public override bool OnExiting(ScreenExitEvent e) return true; } - buttons.State = ButtonSystemState.Exit; + Buttons.State = ButtonSystemState.Exit; OverlayActivationMode.Value = OverlayActivation.Disabled; songTicker.Hide(); diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index 4035d168e136..77db1285bd28 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -48,7 +48,7 @@ public abstract class OsuScreen : Screen, IOsuScreen, IHasDescription /// protected virtual OverlayActivation InitialOverlayActivationMode => OverlayActivation.All; - protected readonly Bindable OverlayActivationMode; + public readonly Bindable OverlayActivationMode; IBindable IOsuScreen.OverlayActivationMode => OverlayActivationMode; diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index 34d7723fa356..7a6f8c8cfb55 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -156,6 +156,7 @@ protected override void LoadComplete() base.LoadComplete(); LocalConfig.SetValue(OsuSetting.IntroSequence, IntroSequence.Circles); + LocalConfig.SetValue(OsuSetting.ShowFirstRunSetup, false); API.Login("Rhythm Champion", "osu!");