diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs index 276a0c3410..946b625608 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs @@ -173,7 +173,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Drawable OverlayContent => InternalChild; - public Drawable FadingContent => (OverlayContent as Container)?.Child; + public new Drawable FadingContent => (OverlayContent as Container)?.Child; } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSkipOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSkipOverlay.cs new file mode 100644 index 0000000000..059af2484d --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSkipOverlay.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. + +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public partial class TestSceneMultiplayerSkipOverlay : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + + AddStep("add skip overlay", () => + { + GameplayClockContainer gameplayClockContainer; + + var working = CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); + + Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new MultiplayerSkipOverlay(120000) + }, + }; + + gameplayClockContainer.Start(); + }); + + AddStep("set playing state", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Playing)); + } + + [Test] + public void TestSkip() + { + for (int i = 0; i < 4; i++) + { + int i2 = i; + + AddStep($"join user {i2}", () => + { + MultiplayerClient.AddUser(new APIUser + { + Id = i2, + Username = $"User {i2}" + }); + + MultiplayerClient.ChangeUserState(i2, MultiplayerUserState.Playing); + }); + } + + AddStep("local user votes", () => MultiplayerClient.VoteToSkipIntro().WaitSafely()); + + for (int i = 0; i < 4; i++) + { + int i2 = i; + AddStep($"user {i2} votes", () => MultiplayerClient.UserVoteToSkipIntro(i2).WaitSafely()); + } + } + } +} diff --git a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs index adb9b92614..c91128401d 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs @@ -149,5 +149,15 @@ namespace osu.Game.Online.Multiplayer /// /// The changed item. Task PlaylistItemChanged(MultiplayerPlaylistItem item); + + /// + /// Signals that a user has requested to skip the beatmap intro. + /// + Task UserVotedToSkipIntro(int userId); + + /// + /// Signals that the vote to skip the beatmap intro has passed. + /// + Task VoteToSkipIntroPassed(); } } diff --git a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index 490973faa2..169d5d1b83 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -112,6 +112,11 @@ namespace osu.Game.Online.Multiplayer /// The item to remove. Task RemovePlaylistItem(long playlistItemId); + /// + /// Votes to skip the beatmap intro. + /// + Task VoteToSkipIntro(); + /// /// Invites a player to the current room. /// diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index df16022e59..8a41c11ae6 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -131,6 +131,9 @@ namespace osu.Game.Online.Multiplayer public event Action? MatchmakingItemDeselected; public event Action? MatchRoomStateChanged; + public event Action? UserVotedToSkipIntro; + public event Action? VoteToSkipIntroPassed; + public event Action? BeatmapAvailabilityChanged; /// @@ -495,6 +498,8 @@ namespace osu.Game.Online.Multiplayer public abstract Task RemovePlaylistItem(long playlistItemId); + public abstract Task VoteToSkipIntro(); + Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) { handleRoomRequest(() => @@ -849,6 +854,10 @@ namespace osu.Game.Online.Multiplayer handleRoomRequest(() => { Debug.Assert(Room != null); + + foreach (var user in Room.Users) + user.VotedToSkipIntro = false; + GameplayStarted?.Invoke(); }); @@ -919,6 +928,37 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } + Task IMultiplayerClient.UserVotedToSkipIntro(int userId) + { + handleRoomRequest(() => + { + Debug.Assert(Room != null); + + var user = Room.Users.SingleOrDefault(u => u.UserID == userId); + + // TODO: user should NEVER be null here, see https://github.com/ppy/osu/issues/17713. + if (user == null) + return; + + user.VotedToSkipIntro = true; + + UserVotedToSkipIntro?.Invoke(userId); + }); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.VoteToSkipIntroPassed() + { + handleRoomRequest(() => + { + Debug.Assert(Room != null); + VoteToSkipIntroPassed?.Invoke(); + }); + + return Task.CompletedTask; + } + /// /// Populates the for a given collection of s. /// diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs index 499e84ce80..d19386c98d 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs @@ -49,6 +49,12 @@ namespace osu.Game.Online.Multiplayer [Key(6)] public int? BeatmapId; + /// + /// Whether this user voted to skip the beatmap intro. + /// + [Key(7)] + public bool VotedToSkipIntro; + [IgnoreMember] public APIUser? User { get; set; } diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index 0decff7ab3..1319578c06 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -70,7 +70,8 @@ namespace osu.Game.Online.Multiplayer connection.On(nameof(IMultiplayerClient.PlaylistItemAdded), ((IMultiplayerClient)this).PlaylistItemAdded); connection.On(nameof(IMultiplayerClient.PlaylistItemRemoved), ((IMultiplayerClient)this).PlaylistItemRemoved); connection.On(nameof(IMultiplayerClient.PlaylistItemChanged), ((IMultiplayerClient)this).PlaylistItemChanged); - connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMultiplayerClient)this).DisconnectRequested); + connection.On(nameof(IMultiplayerClient.UserVotedToSkipIntro), ((IMultiplayerClient)this).UserVotedToSkipIntro); + connection.On(nameof(IMultiplayerClient.VoteToSkipIntroPassed), ((IMultiplayerClient)this).VoteToSkipIntroPassed); connection.On(nameof(IMatchmakingClient.MatchmakingQueueJoined), ((IMatchmakingClient)this).MatchmakingQueueJoined); connection.On(nameof(IMatchmakingClient.MatchmakingQueueLeft), ((IMatchmakingClient)this).MatchmakingQueueLeft); @@ -80,6 +81,8 @@ namespace osu.Game.Online.Multiplayer connection.On(nameof(IMatchmakingClient.MatchmakingQueueStatusChanged), ((IMatchmakingClient)this).MatchmakingQueueStatusChanged); connection.On(nameof(IMatchmakingClient.MatchmakingItemSelected), ((IMatchmakingClient)this).MatchmakingItemSelected); connection.On(nameof(IMatchmakingClient.MatchmakingItemDeselected), ((IMatchmakingClient)this).MatchmakingItemDeselected); + + connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMultiplayerClient)this).DisconnectRequested); }; IsConnected.BindTo(connector.IsConnected); @@ -312,6 +315,16 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.RemovePlaylistItem), playlistItemId); } + public override Task VoteToSkipIntro() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + + return connection.InvokeAsync(nameof(IMultiplayerServer.VoteToSkipIntro)); + } + public override Task DisconnectInternal() { if (connector == null) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 56120120d5..4cc6f3469d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -56,7 +56,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { AllowPause = false, AllowRestart = false, - AllowSkipping = room.AutoSkip, AutomaticallySkipIntro = room.AutoSkip, ShowLeaderboard = true, }) @@ -121,6 +120,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer client.GameplayStarted += onGameplayStarted; client.ResultsReady += onResultsReady; + client.VoteToSkipIntroPassed += onVoteToSkipIntroPassed; ScoreProcessor.HasCompleted.BindValueChanged(_ => { @@ -148,6 +148,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Debug.Assert(client.Room != null); } + protected override SkipOverlay CreateSkipOverlay(double startTime) => new MultiplayerSkipOverlay(startTime); + protected override void StartGameplay() { // We can enter this screen one of two ways: @@ -219,6 +221,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer await Task.WhenAny(resultsReady.Task, Task.Delay(TimeSpan.FromSeconds(60))).ConfigureAwait(false); } + protected override void RequestIntroSkip() + { + // If the room is set up such that the intro is automatically skipped, there's no need to vote on it. + if (Configuration.AutomaticallySkipIntro) + { + base.RequestIntroSkip(); + return; + } + + // No base call because we aren't skipping yet. + client.VoteToSkipIntro().FireAndForget(); + } + + private void onVoteToSkipIntroPassed() + { + Schedule(() => PerformIntroSkip(true)); + } + protected override ResultsScreen CreateResults(ScoreInfo score) { Debug.Assert(Room.RoomID != null); @@ -242,6 +262,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { client.GameplayStarted -= onGameplayStarted; client.ResultsReady -= onResultsReady; + client.VoteToSkipIntroPassed -= onVoteToSkipIntroPassed; } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerSkipOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerSkipOverlay.cs new file mode 100644 index 0000000000..35e85c3273 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerSkipOverlay.cs @@ -0,0 +1,133 @@ +// 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.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Play; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public partial class MultiplayerSkipOverlay : SkipOverlay + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private Drawable votedIcon = null!; + private OsuSpriteText countText = null!; + + public MultiplayerSkipOverlay(double startTime) + : base(startTime) + { + } + + [BackgroundDependencyLoader] + private void load() + { + FadingContent.AddRange( + [ + votedIcon = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Position = new Vector2(50, 0), + Size = new Vector2(20), + Alpha = 0, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Green + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(0.5f), + Icon = FontAwesome.Solid.Check + } + } + }, + countText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + Position = new Vector2(0.75f, 0), + Font = OsuFont.Default.With(size: 36, weight: FontWeight.Bold) + } + ]); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.UserLeft += onUserLeft; + client.UserStateChanged += onUserStateChanged; + client.UserVotedToSkipIntro += onUserVotedToSkipIntro; + + updateText(); + } + + private void onUserLeft(MultiplayerRoomUser user) + { + Schedule(updateText); + } + + private void onUserStateChanged(MultiplayerRoomUser user, MultiplayerUserState state) + { + Schedule(updateText); + } + + private void onUserVotedToSkipIntro(int userId) => Schedule(() => + { + updateText(); + + countText.ScaleTo(1.5f).ScaleTo(1, 200, Easing.OutSine); + + if (userId == client.LocalUser?.UserID) + { + votedIcon.ScaleTo(1.5f).ScaleTo(1, 200, Easing.OutSine); + votedIcon.FadeInFromZero(100); + } + }); + + private void updateText() + { + if (client.Room == null || client.Room.Settings.AutoSkip) + return; + + int countTotal = client.Room.Users.Count(u => u.State == MultiplayerUserState.Playing); + int countSkipped = client.Room.Users.Count(u => u.State == MultiplayerUserState.Playing && u.VotedToSkipIntro); + int countRequired = countTotal / 2 + 1; + + countText.Text = $"{Math.Min(countRequired, countSkipped)} / {countRequired}"; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.UserLeft -= onUserLeft; + client.UserStateChanged -= onUserStateChanged; + client.UserVotedToSkipIntro -= onUserVotedToSkipIntro; + } + } + } +} diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index 07ecb5a5fb..c9db6009d0 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -115,14 +115,15 @@ namespace osu.Game.Screens.Play /// /// Skip forward to the next valid skip point. /// - public void Skip() + /// true to skip as close to gameplay as possible, or false to skip only to the next valid skip point. + public void Skip(bool fullLength = false) { if (GameplayClock.CurrentTime > GameplayStartTime - MINIMUM_SKIP_TIME) return; double skipTarget = GameplayStartTime - MINIMUM_SKIP_TIME; - if (StartTime < -10000 && GameplayClock.CurrentTime < 0 && skipTarget > 6000) + if (!fullLength && StartTime < -10000 && GameplayClock.CurrentTime < 0 && skipTarget > 6000) // double skip exception for storyboards with very long intros skipTarget = 0; diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 22fb8a3463..6158118c78 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -154,7 +154,7 @@ namespace osu.Game.Screens.Play private BreakTracker breakTracker; - private SkipOverlay skipIntroOverlay; + protected SkipOverlay SkipIntroOverlay { get; private set; } private SkipOverlay skipOutroOverlay; protected ScoreProcessor ScoreProcessor { get; private set; } @@ -500,10 +500,10 @@ namespace osu.Game.Screens.Play }, // display the cursor above some HUD elements. DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), - skipIntroOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) + SkipIntroOverlay = CreateSkipOverlay(DrawableRuleset.GameplayStartTime).With(o => { - RequestSkip = performUserRequestedSkip - }, + o.RequestSkip = RequestIntroSkip; + }), skipOutroOverlay = new SkipOverlay(GameplayState.Storyboard.LatestEventTime ?? 0) { RequestSkip = () => progressToResults(false), @@ -522,13 +522,15 @@ namespace osu.Game.Screens.Play if (!Configuration.AllowSkipping || !DrawableRuleset.AllowGameplayOverlays) { - skipIntroOverlay.Expire(); + SkipIntroOverlay.Expire(); skipOutroOverlay.Expire(); } return container; } + protected virtual SkipOverlay CreateSkipOverlay(double startTime) => new SkipOverlay(startTime); + private void onBreakTimeChanged(ValueChangedEvent isBreakTime) { updateGameplayState(); @@ -701,13 +703,22 @@ namespace osu.Game.Screens.Play return true; } - private void performUserRequestedSkip() + protected virtual void RequestIntroSkip() + { + PerformIntroSkip(); + } + + /// + /// Skip forward to the next valid skip point. + /// + /// true to skip as close to gameplay as possible, or false to skip only to the next valid skip point. + protected void PerformIntroSkip(bool fullLength = false) { // user requested skip // disable sample playback to stop currently playing samples and perform skip samplePlaybackDisabled.Value = true; - (GameplayClockContainer as MasterGameplayClockContainer)?.Skip(); + (GameplayClockContainer as MasterGameplayClockContainer)?.Skip(fullLength); // return samplePlaybackDisabled.Value to what is defined by the beatmap's current state updateSampleDisabledState(); @@ -1153,7 +1164,7 @@ namespace osu.Game.Screens.Play GameplayClockContainer.Reset(startClock: true); if (Configuration.AutomaticallySkipIntro) - skipIntroOverlay.SkipWhenReady(); + SkipIntroOverlay.SkipWhenReady(); } public override void OnSuspending(ScreenTransitionEvent e) diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index be8517d9a0..700ea2e532 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -38,20 +38,21 @@ namespace osu.Game.Screens.Play private readonly double startTime; public Action RequestSkip; + + protected FadeContainer FadingContent { get; private set; } + private Button button; private ButtonContainer buttonContainer; private Circle remainingTimeBox; - private FadeContainer fadeContainer; private double displayTime; - private bool isClickable; private bool skipQueued; [Resolved] private IGameplayClock gameplayClock { get; set; } - internal bool IsButtonVisible => fadeContainer.State == Visibility.Visible && buttonContainer.State.Value == Visibility.Visible; + internal bool IsButtonVisible => FadingContent.State == Visibility.Visible && buttonContainer.State.Value == Visibility.Visible; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; /// @@ -77,7 +78,7 @@ namespace osu.Game.Screens.Play InternalChild = buttonContainer = new ButtonContainer { RelativeSizeAxes = Axes.Both, - Child = fadeContainer = new FadeContainer + Child = FadingContent = new FadeContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] @@ -107,13 +108,13 @@ namespace osu.Game.Screens.Play public override void Hide() { base.Hide(); - fadeContainer.Hide(); + FadingContent.Hide(); } public override void Show() { base.Show(); - fadeContainer.TriggerShow(); + FadingContent.TriggerShow(); } protected override void LoadComplete() @@ -136,7 +137,7 @@ namespace osu.Game.Screens.Play RequestSkip?.Invoke(); }; - fadeContainer.TriggerShow(); + FadingContent.TriggerShow(); } /// @@ -183,7 +184,7 @@ namespace osu.Game.Screens.Play protected override bool OnMouseMove(MouseMoveEvent e) { if (isClickable && !e.HasAnyButtonPressed) - fadeContainer.TriggerShow(); + FadingContent.TriggerShow(); return base.OnMouseMove(e); } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 5b2876a989..38070d953e 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -561,6 +561,16 @@ namespace osu.Game.Tests.Visual.Multiplayer public override Task RemovePlaylistItem(long playlistItemId) => RemoveUserPlaylistItem(api.LocalUser.Value.OnlineID, clone(playlistItemId)); + public override Task VoteToSkipIntro() + { + return UserVoteToSkipIntro(api.LocalUser.Value.OnlineID); + } + + public async Task UserVoteToSkipIntro(int userId) + { + await ((IMultiplayerClient)this).UserVotedToSkipIntro(userId).ConfigureAwait(false); + } + protected override Task CreateRoomInternal(MultiplayerRoom room) { Room apiRoom = new Room(room)