diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 6a69917fb4..8e45d99eae 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -7,6 +7,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Containers; @@ -17,6 +18,7 @@ using osu.Framework.Screens; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -56,6 +58,9 @@ namespace osu.Game.Tests.Visual.Multiplayer private TestMultiplayerClient multiplayerClient => multiplayerComponents.MultiplayerClient; private TestMultiplayerRoomManager roomManager => multiplayerComponents.RoomManager; + [Resolved] + private OsuConfigManager config { get; set; } + [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { @@ -668,6 +673,43 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for results", () => multiplayerComponents.CurrentScreen is ResultsScreen); } + [Test] + public void TestGameplayExitFlow() + { + Bindable holdDelay = null; + + AddStep("Set hold delay to zero", () => + { + holdDelay = config.GetBindable(OsuSetting.UIHoldActivationDelay); + holdDelay.Value = 0; + }); + + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + } + } + }); + + enterGameplay(); + + AddUntilStep("wait for playing", () => this.ChildrenOfType().FirstOrDefault()?.LocalUserPlaying.Value == true); + + AddStep("attempt exit without hold", () => InputManager.Key(Key.Escape)); + AddAssert("still in gameplay", () => multiplayerComponents.CurrentScreen is Player); + + AddStep("attempt exit with hold", () => InputManager.PressKey(Key.Escape)); + AddUntilStep("wait for lounge", () => multiplayerComponents.CurrentScreen is Screens.OnlinePlay.Multiplayer.Multiplayer); + + AddStep("stop holding", () => InputManager.ReleaseKey(Key.Escape)); + AddStep("set hold delay to default", () => holdDelay.SetDefault()); + } + [Test] public void TestGameplayDoesntStartWithNonLoadedUser() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs index a3ce4b734b..c92a738b56 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs @@ -18,6 +18,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface @@ -164,7 +165,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); assertCustomisationToggleState(disabled: false, active: true); - AddStep("dismiss mod customisation via mouse", () => + AddStep("dismiss mod customisation via toggle", () => { InputManager.MoveMouseTo(modSelectScreen.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); @@ -191,6 +192,29 @@ namespace osu.Game.Tests.Visual.UserInterface assertCustomisationToggleState(disabled: true, active: false); // config was dismissed without explicit user action. } + [Test] + public void TestDismissCustomisationViaDimmedArea() + { + createScreen(); + assertCustomisationToggleState(disabled: true, active: false); + + AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + assertCustomisationToggleState(disabled: false, active: true); + + AddStep("move mouse to settings area", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("move mouse to dimmed area", () => + { + InputManager.MoveMouseTo(new Vector2( + modSelectScreen.ScreenSpaceDrawQuad.TopLeft.X, + (modSelectScreen.ScreenSpaceDrawQuad.TopLeft.Y + modSelectScreen.ScreenSpaceDrawQuad.BottomLeft.Y) / 2)); + }); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + assertCustomisationToggleState(disabled: false, active: false); + + AddStep("move mouse to first mod panel", () => InputManager.MoveMouseTo(modSelectScreen.ChildrenOfType().First())); + AddAssert("first mod panel is hovered", () => modSelectScreen.ChildrenOfType().First().IsHovered); + } + /// /// Ensure that two mod overlays are not cross polluting via central settings instances. /// diff --git a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs index 1b802a0a14..28afd082c3 100644 --- a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs +++ b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs @@ -10,12 +10,32 @@ using osu.Game.Configuration; namespace osu.Game.Graphics.Containers { + /// + /// A container which adds a common "hold-to-perform" pattern to a container. + /// + /// + /// This container does not handle triggering the hold/abort operations. + /// To use this class, please call and when necessary. + /// + /// The is exposed as a transforming bindable which smoothly tracks the progress of a hold operation. + /// It can be used for animating and displaying progress directly. + /// public abstract class HoldToConfirmContainer : Container { - public Action Action; + public const double DANGEROUS_HOLD_ACTIVATION_DELAY = 500; private const int fadeout_delay = 200; + /// + /// Whether the associated action is considered dangerous, warranting a longer hold. + /// + public bool IsDangerousAction { get; } + + /// + /// The action to perform when a hold successfully completes. + /// + public Action Action; + /// /// Whether currently in a fired state (and the confirm has been sent). /// @@ -23,46 +43,61 @@ namespace osu.Game.Graphics.Containers private bool confirming; + /// + /// The current activation delay for this control. + /// + public IBindable HoldActivationDelay => holdActivationDelay; + + /// + /// The progress of any ongoing hold operation. 0 means no hold has started; 1 means a hold has been completed. + /// + public IBindable Progress => progress; + /// /// Whether the overlay should be allowed to return from a fired state. /// protected virtual bool AllowMultipleFires => false; - /// - /// Specify a custom activation delay, overriding the game-wide user setting. - /// - /// - /// This should be used in special cases where we want to be extra sure the user knows what they are doing. An example is when changes would be lost. - /// - protected virtual double? HoldActivationDelay => null; + private readonly Bindable progress = new BindableDouble(); - public Bindable Progress = new BindableDouble(); + private readonly Bindable holdActivationDelay = new Bindable(); - private Bindable holdActivationDelay; + [Resolved] + private OsuConfigManager config { get; set; } - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + protected HoldToConfirmContainer(bool isDangerousAction = false) { - holdActivationDelay = HoldActivationDelay != null - ? new Bindable(HoldActivationDelay.Value) - : config.GetBindable(OsuSetting.UIHoldActivationDelay); + IsDangerousAction = isDangerousAction; } + protected override void LoadComplete() + { + base.LoadComplete(); + + if (IsDangerousAction) + holdActivationDelay.Value = DANGEROUS_HOLD_ACTIVATION_DELAY; + else + config.BindWith(OsuSetting.UIHoldActivationDelay, holdActivationDelay); + } + + /// + /// Begin a new confirmation. Should be called when the container is interacted with (ie. the user presses a key). + /// + /// + /// Calling this method when already in the process of confirming has no effect. + /// protected void BeginConfirm() { if (confirming || (!AllowMultipleFires && Fired)) return; confirming = true; - this.TransformBindableTo(Progress, 1, holdActivationDelay.Value * (1 - Progress.Value), Easing.Out).OnComplete(_ => Confirm()); - } - - protected virtual void Confirm() - { - Action?.Invoke(); - Fired = true; + this.TransformBindableTo(progress, 1, holdActivationDelay.Value * (1 - progress.Value), Easing.Out).OnComplete(_ => Confirm()); } + /// + /// Abort any ongoing confirmation. Should be called when the container's interaction is no longer valid (ie. the user releases a key). + /// protected void AbortConfirm() { if (!AllowMultipleFires && Fired) return; @@ -71,9 +106,19 @@ namespace osu.Game.Graphics.Containers Fired = false; this - .TransformBindableTo(Progress, Progress.Value) + .TransformBindableTo(progress, progress.Value) .Delay(200) - .TransformBindableTo(Progress, 0, fadeout_delay, Easing.InSine); + .TransformBindableTo(progress, 0, fadeout_delay, Easing.InSine); + } + + /// + /// A method which is invoked when the confirmation sequence completes successfully. + /// By default, will fire the associated . + /// + protected virtual void Confirm() + { + Action?.Invoke(); + Fired = true; } } } diff --git a/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs index adc627e15b..6239c5e409 100644 --- a/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs +++ b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs @@ -42,7 +42,10 @@ namespace osu.Game.Overlays.Dialog private class DangerousConfirmContainer : HoldToConfirmContainer { - protected override double? HoldActivationDelay => 500; + public DangerousConfirmContainer() + : base(isDangerousAction: true) + { + } protected override bool OnMouseDown(MouseDownEvent e) { diff --git a/osu.Game/Overlays/Mods/ModSelectScreen.cs b/osu.Game/Overlays/Mods/ModSelectScreen.cs index a7f8a167f9..a1b4d7d8a0 100644 --- a/osu.Game/Overlays/Mods/ModSelectScreen.cs +++ b/osu.Game/Overlays/Mods/ModSelectScreen.cs @@ -464,6 +464,8 @@ namespace osu.Game.Overlays.Mods public Action? OnClicked { get; set; } + public override bool HandlePositionalInput => base.HandlePositionalInput && HandleMouse.Value; + protected override bool Handle(UIEvent e) { if (!HandleMouse.Value) diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs index e807dbd482..c6cc09a16c 100644 --- a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; @@ -25,7 +24,7 @@ namespace osu.Game.Rulesets.Edit public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && anyToolboxHovered(screenSpacePos); - private bool anyToolboxHovered(Vector2 screenSpacePos) => FillFlow.Children.Any(d => d.ScreenSpaceDrawQuad.Contains(screenSpacePos)); + private bool anyToolboxHovered(Vector2 screenSpacePos) => FillFlow.ScreenSpaceDrawQuad.Contains(screenSpacePos); protected override bool OnMouseDown(MouseDownEvent e) => true; diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 3da63ec2cc..2ba76d0896 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -12,13 +12,14 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Threading; using osu.Framework.Utils; -using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { @@ -28,20 +29,22 @@ namespace osu.Game.Screens.Play.HUD public readonly Bindable IsPaused = new Bindable(); - private readonly Button button; + private HoldButton button; - public Action Action - { - set => button.Action = value; - } + public Action Action { get; set; } - private readonly OsuSpriteText text; + private OsuSpriteText text; public HoldForMenuButton() { Direction = FillDirection.Horizontal; Spacing = new Vector2(20, 0); Margin = new MarginPadding(10); + } + + [BackgroundDependencyLoader(true)] + private void load(Player player) + { Children = new Drawable[] { text = new OsuSpriteText @@ -50,25 +53,20 @@ namespace osu.Game.Screens.Play.HUD Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft }, - button = new Button + button = new HoldButton(player?.Configuration.AllowRestart == false) { HoverGained = () => text.FadeIn(500, Easing.OutQuint), HoverLost = () => text.FadeOut(500, Easing.OutQuint), - IsPaused = { BindTarget = IsPaused } + IsPaused = { BindTarget = IsPaused }, + Action = () => Action(), } }; AutoSizeAxes = Axes.Both; } - [Resolved] - private OsuConfigManager config { get; set; } - - private Bindable activationDelay; - protected override void LoadComplete() { - activationDelay = config.GetBindable(OsuSetting.UIHoldActivationDelay); - activationDelay.BindValueChanged(v => + button.HoldActivationDelay.BindValueChanged(v => { text.Text = v.NewValue > 0 ? "hold for menu" @@ -102,7 +100,7 @@ namespace osu.Game.Screens.Play.HUD } } - private class Button : HoldToConfirmContainer, IKeyBindingHandler + private class HoldButton : HoldToConfirmContainer, IKeyBindingHandler { private SpriteIcon icon; private CircularProgress circularProgress; @@ -115,6 +113,16 @@ namespace osu.Game.Screens.Play.HUD public Action HoverGained; public Action HoverLost; + private const double shake_duration = 20; + + private bool pendingAnimation; + private ScheduledDelegate shakeOperation; + + public HoldButton(bool isDangerousAction) + : base(isDangerousAction) + { + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -161,11 +169,38 @@ namespace osu.Game.Screens.Play.HUD private void bind() { - circularProgress.Current.BindTo(Progress); - Progress.ValueChanged += progress => icon.Scale = new Vector2(1 + (float)progress.NewValue * 0.2f); + ((IBindable)circularProgress.Current).BindTo(Progress); + Progress.ValueChanged += progress => + { + icon.Scale = new Vector2(1 + (float)progress.NewValue * 0.2f); + + if (IsDangerousAction) + { + Colour = Interpolation.ValueAt(progress.NewValue, Color4.White, Color4.Red, 0, 1, Easing.OutQuint); + + if (progress.NewValue > 0 && progress.NewValue < 1) + { + shakeOperation ??= Scheduler.AddDelayed(shake, shake_duration, true); + } + else + { + Child.MoveTo(Vector2.Zero, shake_duration * 2, Easing.OutQuint); + shakeOperation?.Cancel(); + shakeOperation = null; + } + } + }; } - private bool pendingAnimation; + private void shake() + { + const float shake_magnitude = 8; + + Child.MoveTo(new Vector2( + RNG.NextSingle(-1, 1) * (float)Progress.Value * shake_magnitude, + RNG.NextSingle(-1, 1) * (float)Progress.Value * shake_magnitude + ), shake_duration); + } protected override void Confirm() {