1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-18 16:10:48 +08:00

Merge pull request #35542 from smoogipoo/mp-vote-to-skip

Implement vote-to-skip in multiplayer
This commit is contained in:
Bartłomiej Dach
2025-11-05 10:06:59 +01:00
committed by GitHub
Unverified
13 changed files with 345 additions and 21 deletions
@@ -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;
}
}
}
@@ -0,0 +1,73 @@
// 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 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());
}
}
}
}
@@ -149,5 +149,15 @@ namespace osu.Game.Online.Multiplayer
/// </summary>
/// <param name="item">The changed item.</param>
Task PlaylistItemChanged(MultiplayerPlaylistItem item);
/// <summary>
/// Signals that a user has requested to skip the beatmap intro.
/// </summary>
Task UserVotedToSkipIntro(int userId);
/// <summary>
/// Signals that the vote to skip the beatmap intro has passed.
/// </summary>
Task VoteToSkipIntroPassed();
}
}
@@ -112,6 +112,11 @@ namespace osu.Game.Online.Multiplayer
/// <param name="playlistItemId">The item to remove.</param>
Task RemovePlaylistItem(long playlistItemId);
/// <summary>
/// Votes to skip the beatmap intro.
/// </summary>
Task VoteToSkipIntro();
/// <summary>
/// Invites a player to the current room.
/// </summary>
@@ -131,6 +131,9 @@ namespace osu.Game.Online.Multiplayer
public event Action<int, long>? MatchmakingItemDeselected;
public event Action<MatchRoomState>? MatchRoomStateChanged;
public event Action<int>? UserVotedToSkipIntro;
public event Action? VoteToSkipIntroPassed;
public event Action<MultiplayerRoomUser, BeatmapAvailability>? BeatmapAvailabilityChanged;
/// <summary>
@@ -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;
}
/// <summary>
/// Populates the <see cref="APIUser"/> for a given collection of <see cref="MultiplayerRoomUser"/>s.
/// </summary>
@@ -49,6 +49,12 @@ namespace osu.Game.Online.Multiplayer
[Key(6)]
public int? BeatmapId;
/// <summary>
/// Whether this user voted to skip the beatmap intro.
/// </summary>
[Key(7)]
public bool VotedToSkipIntro;
[IgnoreMember]
public APIUser? User { get; set; }
@@ -70,7 +70,8 @@ namespace osu.Game.Online.Multiplayer
connection.On<MultiplayerPlaylistItem>(nameof(IMultiplayerClient.PlaylistItemAdded), ((IMultiplayerClient)this).PlaylistItemAdded);
connection.On<long>(nameof(IMultiplayerClient.PlaylistItemRemoved), ((IMultiplayerClient)this).PlaylistItemRemoved);
connection.On<MultiplayerPlaylistItem>(nameof(IMultiplayerClient.PlaylistItemChanged), ((IMultiplayerClient)this).PlaylistItemChanged);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMultiplayerClient)this).DisconnectRequested);
connection.On<int>(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<MatchmakingQueueStatus>(nameof(IMatchmakingClient.MatchmakingQueueStatusChanged), ((IMatchmakingClient)this).MatchmakingQueueStatusChanged);
connection.On<int, long>(nameof(IMatchmakingClient.MatchmakingItemSelected), ((IMatchmakingClient)this).MatchmakingItemSelected);
connection.On<int, long>(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)
@@ -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;
}
}
}
@@ -0,0 +1,133 @@
// 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 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;
}
}
}
}
@@ -115,14 +115,15 @@ namespace osu.Game.Screens.Play
/// <summary>
/// Skip forward to the next valid skip point.
/// </summary>
public void Skip()
/// <param name="fullLength"><c>true</c> to skip as close to gameplay as possible, or <c>false</c> to skip only to the next valid skip point.</param>
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;
+19 -8
View File
@@ -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<bool> isBreakTime)
{
updateGameplayState();
@@ -701,13 +703,22 @@ namespace osu.Game.Screens.Play
return true;
}
private void performUserRequestedSkip()
protected virtual void RequestIntroSkip()
{
PerformIntroSkip();
}
/// <summary>
/// Skip forward to the next valid skip point.
/// </summary>
/// <param name="fullLength"><c>true</c> to skip as close to gameplay as possible, or <c>false</c> to skip only to the next valid skip point.</param>
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)
+9 -8
View File
@@ -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;
/// <summary>
@@ -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();
}
/// <summary>
@@ -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);
}
@@ -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<MultiplayerRoom> CreateRoomInternal(MultiplayerRoom room)
{
Room apiRoom = new Room(room)