1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-15 02:42:54 +08:00

Merge pull request #11353 from bdach/disable-repeat-multi-actions

Disable multiplayer action buttons after clicks to prevent double operations
This commit is contained in:
Dean Herbert 2021-01-09 17:42:38 +09:00 committed by GitHub
commit c8d83a9fb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 349 additions and 49 deletions

View File

@ -0,0 +1,55 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Screens.OnlinePlay;
using osu.Game.Screens.OnlinePlay.Multiplayer;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneCreateMultiplayerMatchButton : MultiplayerTestScene
{
[Cached]
private OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker();
private CreateMultiplayerMatchButton button;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("create button", () => Child = button = new CreateMultiplayerMatchButton
{
Width = 200,
Height = 100,
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
}
[Test]
public void TestButtonEnableStateChanges()
{
IDisposable joiningRoomOperation = null;
assertButtonEnableState(true);
AddStep("begin joining room", () => joiningRoomOperation = ongoingOperationTracker.BeginOperation());
assertButtonEnableState(false);
AddStep("end joining room", () => joiningRoomOperation.Dispose());
assertButtonEnableState(true);
AddStep("disconnect client", () => Client.Disconnect());
assertButtonEnableState(false);
AddStep("re-connect client", () => Client.Connect());
assertButtonEnableState(true);
}
private void assertButtonEnableState(bool enabled)
=> AddAssert($"button {(enabled ? "enabled" : "disabled")}", () => button.Enabled.Value == enabled);
}
}

View File

@ -3,10 +3,12 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
using osu.Game.Tests.Beatmaps;
@ -18,6 +20,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
private MultiplayerMatchSubScreen screen;
[Cached]
private OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker();
public TestSceneMultiplayerMatchSubScreen()
: base(false)
{

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
@ -30,6 +31,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
private BeatmapManager beatmaps;
private RulesetStore rulesets;
private IDisposable readyClickOperation;
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
{
@ -56,6 +59,19 @@ namespace osu.Game.Tests.Visual.Multiplayer
Beatmap = { Value = Beatmap.Value.BeatmapInfo },
Ruleset = { Value = Beatmap.Value.BeatmapInfo.Ruleset }
}
},
OnReadyClick = async () =>
{
readyClickOperation = OngoingOperationTracker.BeginOperation();
if (Client.IsHost && Client.LocalUser?.State == MultiplayerUserState.Ready)
{
await Client.StartMatch();
return;
}
await Client.ToggleReady();
readyClickOperation.Dispose();
}
};
});
@ -108,8 +124,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
addClickButtonStep();
AddAssert("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready);
addClickButtonStep();
AddAssert("match started", () => Client.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad);
verifyGameplayStartFlow();
}
[Test]
@ -124,8 +139,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
addClickButtonStep();
AddStep("make user host", () => Client.TransferHost(Client.Room?.Users[0].UserID ?? 0));
addClickButtonStep();
AddAssert("match started", () => Client.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad);
verifyGameplayStartFlow();
}
[Test]
@ -179,5 +193,15 @@ namespace osu.Game.Tests.Visual.Multiplayer
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});
private void verifyGameplayStartFlow()
{
addClickButtonStep();
AddAssert("user waiting for load", () => Client.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad);
AddAssert("ready button disabled", () => !button.ChildrenOfType<OsuButton>().Single().Enabled.Value);
AddStep("transitioned to gameplay", () => readyClickOperation.Dispose());
AddAssert("ready button enabled", () => button.ChildrenOfType<OsuButton>().Single().Enabled.Value);
}
}
}

View File

@ -65,6 +65,23 @@ namespace osu.Game.Online.Multiplayer
/// </summary>
public readonly BindableList<int> CurrentMatchPlayingUserIds = new BindableList<int>();
/// <summary>
/// The <see cref="MultiplayerRoomUser"/> corresponding to the local player, if available.
/// </summary>
public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == api.LocalUser.Value.Id);
/// <summary>
/// Whether the <see cref="LocalUser"/> is the host in <see cref="Room"/>.
/// </summary>
public bool IsHost
{
get
{
var localUser = LocalUser;
return localUser != null && Room?.Host != null && localUser.Equals(Room.Host);
}
}
[Resolved]
private UserLookupCache userLookupCache { get; set; } = null!;
@ -178,6 +195,32 @@ namespace osu.Game.Online.Multiplayer
});
}
/// <summary>
/// Toggles the <see cref="LocalUser"/>'s ready state.
/// </summary>
/// <exception cref="InvalidOperationException">If a toggle of ready state is not valid at this time.</exception>
public async Task ToggleReady()
{
var localUser = LocalUser;
if (localUser == null)
return;
switch (localUser.State)
{
case MultiplayerUserState.Idle:
await ChangeState(MultiplayerUserState.Ready);
return;
case MultiplayerUserState.Ready:
await ChangeState(MultiplayerUserState.Idle);
return;
default:
throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}");
}
}
public abstract Task TransferHost(int userId);
public abstract Task ChangeSettings(MultiplayerRoomSettings settings);

View File

@ -1,7 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -26,6 +29,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
protected override UserActivity InitialActivity => new UserActivity.SearchingForLobby();
private readonly IBindable<bool> initialRoomsReceived = new Bindable<bool>();
private readonly IBindable<bool> operationInProgress = new Bindable<bool>();
private FilterControl filter;
private Container content;
@ -37,7 +41,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
[Resolved]
private MusicController music { get; set; }
private bool joiningRoom;
[Resolved(CanBeNull = true)]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
[CanBeNull]
private IDisposable joiningRoomOperation { get; set; }
[BackgroundDependencyLoader]
private void load()
@ -98,7 +106,13 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
base.LoadComplete();
initialRoomsReceived.BindTo(RoomManager.InitialRoomsReceived);
initialRoomsReceived.BindValueChanged(onInitialRoomsReceivedChanged, true);
initialRoomsReceived.BindValueChanged(_ => updateLoadingLayer());
if (ongoingOperationTracker != null)
{
operationInProgress.BindTo(ongoingOperationTracker.InProgress);
operationInProgress.BindValueChanged(_ => updateLoadingLayer(), true);
}
}
protected override void UpdateAfterChildren()
@ -156,26 +170,24 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
private void joinRequested(Room room)
{
joiningRoom = true;
updateLoadingLayer();
Debug.Assert(joiningRoomOperation == null);
joiningRoomOperation = ongoingOperationTracker?.BeginOperation();
RoomManager?.JoinRoom(room, r =>
{
Open(room);
joiningRoom = false;
updateLoadingLayer();
joiningRoomOperation?.Dispose();
joiningRoomOperation = null;
}, _ =>
{
joiningRoom = false;
updateLoadingLayer();
joiningRoomOperation?.Dispose();
joiningRoomOperation = null;
});
}
private void onInitialRoomsReceivedChanged(ValueChangedEvent<bool> received) => updateLoadingLayer();
private void updateLoadingLayer()
{
if (joiningRoom || !initialRoomsReceived.Value)
if (operationInProgress.Value || !initialRoomsReceived.Value)
loadingLayer.Show();
else
loadingLayer.Hide();

View File

@ -10,14 +10,34 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
public class CreateMultiplayerMatchButton : PurpleTriangleButton
{
private IBindable<bool> isConnected;
private IBindable<bool> operationInProgress;
[Resolved]
private StatefulMultiplayerClient multiplayerClient { get; set; }
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
[BackgroundDependencyLoader]
private void load(StatefulMultiplayerClient multiplayerClient)
private void load()
{
Triangles.TriangleScale = 1.5f;
Text = "Create room";
((IBindable<bool>)Enabled).BindTo(multiplayerClient.IsConnected);
isConnected = multiplayerClient.IsConnected.GetBoundCopy();
operationInProgress = ongoingOperationTracker.InProgress.GetBoundCopy();
}
protected override void LoadComplete()
{
base.LoadComplete();
isConnected.BindValueChanged(_ => updateState());
operationInProgress.BindValueChanged(_ => updateState(), true);
}
private void updateState() => Enabled.Value = isConnected.Value && !operationInProgress.Value;
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
@ -19,7 +20,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
public readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>();
public Action OnReadyClick
{
set => readyButton.OnReadyClick = value;
}
private readonly Drawable background;
private readonly MultiplayerReadyButton readyButton;
public MultiplayerMatchFooter()
{
@ -29,7 +36,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
InternalChildren = new[]
{
background = new Box { RelativeSizeAxes = Axes.Both },
new MultiplayerReadyButton
readyButton = new MultiplayerReadyButton
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,

View File

@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
@ -68,6 +70,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
[Resolved]
private Bindable<RulesetInfo> ruleset { get; set; }
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
private readonly IBindable<bool> operationInProgress = new BindableBool();
[CanBeNull]
private IDisposable applyingSettingsOperation;
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
@ -265,13 +275,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
Type.BindValueChanged(type => TypePicker.Current.Value = type.NewValue, true);
MaxParticipants.BindValueChanged(count => MaxParticipantsField.Text = count.NewValue?.ToString(), true);
RoomID.BindValueChanged(roomId => initialBeatmapControl.Alpha = roomId.NewValue == null ? 1 : 0, true);
operationInProgress.BindTo(ongoingOperationTracker.InProgress);
operationInProgress.BindValueChanged(v =>
{
if (v.NewValue)
loadingLayer.Show();
else
loadingLayer.Hide();
});
}
protected override void Update()
{
base.Update();
ApplyButton.Enabled.Value = Playlist.Count > 0 && NameField.Text.Length > 0;
ApplyButton.Enabled.Value = Playlist.Count > 0 && NameField.Text.Length > 0 && !operationInProgress.Value;
}
private void apply()
@ -280,7 +299,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
return;
hideError();
loadingLayer.Show();
Debug.Assert(applyingSettingsOperation == null);
applyingSettingsOperation = ongoingOperationTracker.BeginOperation();
// If the client is already in a room, update via the client.
// Otherwise, update the room directly in preparation for it to be submitted to the API on match creation.
@ -313,16 +334,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void onSuccess(Room room)
{
loadingLayer.Hide();
Debug.Assert(applyingSettingsOperation != null);
SettingsApplied?.Invoke();
applyingSettingsOperation.Dispose();
applyingSettingsOperation = null;
}
private void onError(string text)
{
Debug.Assert(applyingSettingsOperation != null);
ErrorText.Text = text;
ErrorText.FadeIn(50);
loadingLayer.Hide();
applyingSettingsOperation.Dispose();
applyingSettingsOperation = null;
}
}

View File

@ -1,15 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Online.API;
@ -24,15 +23,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
public Bindable<PlaylistItem> SelectedItem => button.SelectedItem;
public Action OnReadyClick
{
set => button.Action = value;
}
[Resolved]
private IAPIProvider api { get; set; }
[CanBeNull]
private MultiplayerRoomUser localUser;
[Resolved]
private OsuColour colours { get; set; }
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
private IBindable<bool> operationInProgress;
private SampleChannel sampleReadyCount;
private readonly ButtonWithTrianglesExposed button;
@ -46,7 +52,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
RelativeSizeAxes = Axes.Both,
Size = Vector2.One,
Enabled = { Value = true },
Action = onClick
};
}
@ -54,21 +59,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void load(AudioManager audio)
{
sampleReadyCount = audio.Samples.Get(@"SongSelect/select-difficulty");
operationInProgress = ongoingOperationTracker.InProgress.GetBoundCopy();
operationInProgress.BindValueChanged(_ => updateState());
}
protected override void OnRoomUpdated()
{
base.OnRoomUpdated();
// this method is called on leaving the room, so the local user may not exist in the room any more.
localUser = Room?.Users.SingleOrDefault(u => u.User?.Id == api.LocalUser.Value.Id);
button.Enabled.Value = Client.Room?.State == MultiplayerRoomState.Open;
updateState();
}
private void updateState()
{
var localUser = Client.LocalUser;
if (localUser == null)
return;
@ -100,6 +106,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
break;
}
button.Enabled.Value = Client.Room?.State == MultiplayerRoomState.Open && !operationInProgress.Value;
if (newCountReady != countReady)
{
countReady = newCountReady;
@ -132,22 +140,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
}
}
private void onClick()
{
if (localUser == null)
return;
if (localUser.State == MultiplayerUserState.Idle)
Client.ChangeState(MultiplayerUserState.Ready).CatchUnobservedExceptions(true);
else
{
if (Room?.Host?.Equals(localUser) == true)
Client.StartMatch().CatchUnobservedExceptions(true);
else
Client.ChangeState(MultiplayerUserState.Idle).CatchUnobservedExceptions(true);
}
}
private class ButtonWithTrianglesExposed : ReadyButton
{
public new Triangles Triangles => base.Triangles;

View File

@ -1,14 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Game.Extensions;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Components;
@ -31,10 +34,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
[Resolved]
private StatefulMultiplayerClient client { get; set; }
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
private MultiplayerMatchSettingsOverlay settingsOverlay;
private IBindable<bool> isConnected;
[CanBeNull]
private IDisposable readyClickOperation;
public MultiplayerMatchSubScreen(Room room)
{
Title = room.RoomID.Value == null ? "New room" : room.Name.Value;
@ -150,7 +159,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
},
new Drawable[]
{
new MultiplayerMatchFooter { SelectedItem = { BindTarget = SelectedItem } }
new MultiplayerMatchFooter
{
SelectedItem = { BindTarget = SelectedItem },
OnReadyClick = onReadyClick
}
}
},
RowDimensions = new[]
@ -196,6 +209,44 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private void onPlaylistChanged(object sender, NotifyCollectionChangedEventArgs e) => SelectedItem.Value = Playlist.FirstOrDefault();
private void onReadyClick()
{
Debug.Assert(readyClickOperation == null);
readyClickOperation = ongoingOperationTracker.BeginOperation();
if (client.IsHost && client.LocalUser?.State == MultiplayerUserState.Ready)
{
client.StartMatch()
.ContinueWith(t =>
{
// accessing Exception here silences any potential errors from the antecedent task
if (t.Exception != null)
{
t.CatchUnobservedExceptions(true); // will run immediately.
// gameplay was not started due to an exception; unblock button.
endOperation();
}
// gameplay is starting, the button will be unblocked on load requested.
});
return;
}
client.ToggleReady()
.ContinueWith(t =>
{
t.CatchUnobservedExceptions(true); // will run immediately.
endOperation();
});
void endOperation()
{
Debug.Assert(readyClickOperation != null);
readyClickOperation.Dispose();
readyClickOperation = null;
}
}
private void onLoadRequested()
{
Debug.Assert(client.Room != null);
@ -203,6 +254,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
int[] userIds = client.CurrentMatchPlayingUserIds.ToArray();
StartPlay(() => new MultiplayerPlayer(SelectedItem.Value, userIds));
Debug.Assert(readyClickOperation != null);
readyClickOperation.Dispose();
readyClickOperation = null;
}
protected override void Dispose(bool isDisposing)

View File

@ -0,0 +1,52 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
namespace osu.Game.Screens.OnlinePlay
{
/// <summary>
/// Utility class to track ongoing online operations' progress.
/// Can be used to disable interactivity while waiting for a response from online sources.
/// </summary>
public class OngoingOperationTracker
{
/// <summary>
/// Whether there is an online operation in progress.
/// </summary>
public IBindable<bool> InProgress => inProgress;
private readonly Bindable<bool> inProgress = new BindableBool();
private LeasedBindable<bool> leasedInProgress;
/// <summary>
/// Begins tracking a new online operation.
/// </summary>
/// <returns>
/// An <see cref="IDisposable"/> that will automatically mark the operation as ended on disposal.
/// </returns>
/// <exception cref="InvalidOperationException">An operation has already been started.</exception>
public IDisposable BeginOperation()
{
if (leasedInProgress != null)
throw new InvalidOperationException("Cannot begin operation while another is in progress.");
leasedInProgress = inProgress.BeginLease(true);
leasedInProgress.Value = true;
return new InvokeOnDisposal(endOperation);
}
private void endOperation()
{
if (leasedInProgress == null)
throw new InvalidOperationException("Cannot end operation multiple times.");
leasedInProgress.Return();
leasedInProgress = null;
}
}
}

View File

@ -53,6 +53,9 @@ namespace osu.Game.Screens.OnlinePlay
[Cached]
private readonly Bindable<FilterCriteria> currentFilter = new Bindable<FilterCriteria>(new FilterCriteria());
[Cached]
private readonly OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker();
[Resolved(CanBeNull = true)]
private MusicController music { get; set; }

View File

@ -23,6 +23,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Cached]
public Bindable<FilterCriteria> Filter { get; }
[Cached]
public OngoingOperationTracker OngoingOperationTracker { get; } = new OngoingOperationTracker();
protected override Container<Drawable> Content => content;
private readonly TestMultiplayerRoomContainer content;