Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show spectating users during gameplay #31527

Merged
merged 12 commits into from
Jan 21, 2025
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Screens.Play.HUD;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
Expand Down Expand Up @@ -47,6 +48,7 @@ public CatchLegacySkinTransformer(ISkin skin)
return new DefaultSkinComponentsContainer(container =>
{
var keyCounter = container.OfType<LegacyKeyCounterDisplay>().FirstOrDefault();
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();

if (keyCounter != null)
{
Expand All @@ -55,11 +57,19 @@ public CatchLegacySkinTransformer(ISkin skin)
keyCounter.Origin = Anchor.TopRight;
keyCounter.Position = new Vector2(0, -40) * 1.6f;
}

if (spectatorList != null)
{
spectatorList.Anchor = Anchor.BottomLeft;
spectatorList.Origin = Anchor.BottomLeft;
spectatorList.Position = new Vector2(10, -10);
}
})
{
Children = new Drawable[]
{
new LegacyKeyCounterDisplay(),
new SpectatorList(),
}
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;

namespace osu.Game.Rulesets.Mania.Skinning.Argon
Expand Down Expand Up @@ -39,6 +41,7 @@ public ManiaArgonSkinTransformer(ISkin skin, IBeatmap beatmap)
return new DefaultSkinComponentsContainer(container =>
{
var combo = container.ChildrenOfType<ArgonManiaComboCounter>().FirstOrDefault();
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();

if (combo != null)
{
Expand All @@ -47,9 +50,17 @@ public ManiaArgonSkinTransformer(ISkin skin, IBeatmap beatmap)
combo.Origin = Anchor.Centre;
combo.Y = 200;
}

if (spectatorList != null)
spectatorList.Position = new Vector2(36, -66);
})
{
new ArgonManiaComboCounter(),
new SpectatorList
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
}
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Skinning;
using osuTK;

namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{
Expand Down Expand Up @@ -95,16 +97,25 @@ public override Drawable GetDrawableComponent(ISkinComponentLookup lookup)
return new DefaultSkinComponentsContainer(container =>
{
var combo = container.ChildrenOfType<LegacyManiaComboCounter>().FirstOrDefault();
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();

if (combo != null)
{
combo.Anchor = Anchor.TopCentre;
combo.Origin = Anchor.Centre;
combo.Y = this.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.ComboPosition)?.Value ?? 0;
}

if (spectatorList != null)
{
spectatorList.Anchor = Anchor.BottomLeft;
spectatorList.Origin = Anchor.BottomLeft;
spectatorList.Position = new Vector2(10, -10);
}
})
{
new LegacyManiaComboCounter(),
new SpectatorList(),
};
}

Expand Down
14 changes: 14 additions & 0 deletions osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Play.HUD;
using osu.Game.Skinning;
using osuTK;

Expand Down Expand Up @@ -70,19 +71,32 @@ public OsuLegacySkinTransformer(ISkin skin)
}

var combo = container.OfType<LegacyDefaultComboCounter>().FirstOrDefault();
var spectatorList = container.OfType<SpectatorList>().FirstOrDefault();

Vector2 pos = new Vector2();

if (combo != null)
{
combo.Anchor = Anchor.BottomLeft;
combo.Origin = Anchor.BottomLeft;
combo.Scale = new Vector2(1.28f);

pos += new Vector2(10, -(combo.DrawHeight * 1.56f + 20) * combo.Scale.X);
}

if (spectatorList != null)
{
spectatorList.Anchor = Anchor.BottomLeft;
spectatorList.Origin = Anchor.BottomLeft;
spectatorList.Position = pos;
}
})
{
Children = new Drawable[]
{
new LegacyDefaultComboCounter(),
new LegacyKeyCounterDisplay(),
new SpectatorList(),
}
};
}
Expand Down
Binary file not shown.
2 changes: 2 additions & 0 deletions osu.Game.Tests/Skins/SkinDeserialisationTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ public class SkinDeserialisationTest
"Archives/modified-classic-20240724.osk",
// Covers skinnable mod display
"Archives/modified-default-20241207.osk",
// Covers skinnable spectator list
"Archives/modified-argon-20250116.osk",
};

/// <summary>
Expand Down
52 changes: 39 additions & 13 deletions osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,48 +6,74 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
using osu.Game.Tests.Visual.Spectator;

namespace osu.Game.Tests.Visual.Gameplay
{
[TestFixture]
public partial class TestSceneSpectatorList : OsuTestScene
{
private readonly BindableList<SpectatorList.Spectator> spectators = new BindableList<SpectatorList.Spectator>();
private readonly Bindable<LocalUserPlayingState> localUserPlayingState = new Bindable<LocalUserPlayingState>();

private int counter;

[Test]
public void TestBasics()
{
SpectatorList list = null!;
AddStep("create spectator list", () => Child = list = new SpectatorList
Bindable<LocalUserPlayingState> playingState = new Bindable<LocalUserPlayingState>();
GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), healthProcessor: new OsuHealthProcessor(0), localUserPlayingState: playingState);
TestSpectatorClient client = new TestSpectatorClient();

AddStep("create spectator list", () =>
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Spectators = { BindTarget = spectators },
UserPlayingState = { BindTarget = localUserPlayingState }
Children = new Drawable[]
{
client,
new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies =
[
(typeof(GameplayState), gameplayState),
(typeof(SpectatorClient), client)
],
Child = list = new SpectatorList
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
}
}
};
});

AddStep("start playing", () => localUserPlayingState.Value = LocalUserPlayingState.Playing);
AddStep("start playing", () => playingState.Value = LocalUserPlayingState.Playing);

AddRepeatStep("add a user", () =>
{
int id = Interlocked.Increment(ref counter);
spectators.Add(new SpectatorList.Spectator(id, $"User {id}"));
((ISpectatorClient)client).UserStartedWatching([
new SpectatorUser
{
OnlineID = id,
Username = $"User {id}"
}
]);
}, 10);

AddRepeatStep("remove random user", () => spectators.RemoveAt(RNG.Next(0, spectators.Count)), 5);
AddRepeatStep("remove random user", () => ((ISpectatorClient)client).UserEndedWatching(client.WatchingUsers[RNG.Next(client.WatchingUsers.Count)].OnlineID), 5);

AddStep("change font to venera", () => list.Font.Value = Typeface.Venera);
AddStep("change font to torus", () => list.Font.Value = Typeface.Torus);
AddStep("change header colour", () => list.HeaderColour.Value = new Colour4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1));

AddStep("enter break", () => localUserPlayingState.Value = LocalUserPlayingState.Break);
AddStep("stop playing", () => localUserPlayingState.Value = LocalUserPlayingState.NotPlaying);
AddStep("enter break", () => playingState.Value = LocalUserPlayingState.Break);
AddStep("stop playing", () => playingState.Value = LocalUserPlayingState.NotPlaying);
}
}
}
12 changes: 12 additions & 0 deletions osu.Game/Online/Spectator/ISpectatorClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,17 @@ public interface ISpectatorClient : IStatefulUserHubClient
/// <param name="userId">The ID of the user who achieved the score.</param>
/// <param name="scoreId">The ID of the score.</param>
Task UserScoreProcessed(int userId, long scoreId);

/// <summary>
/// Signals that another user has <see cref="ISpectatorServer.StartWatchingUser">started watching this client</see>.
/// </summary>
/// <param name="user">The information about the user who started watching.</param>
Task UserStartedWatching(SpectatorUser[] user);

/// <summary>
/// Signals that another user has <see cref="ISpectatorServer.EndWatchingUser">ended watching this client</see>
/// </summary>
/// <param name="userId">The ID of the user who ended watching.</param>
Task UserEndedWatching(int userId);
}
}
2 changes: 2 additions & 0 deletions osu.Game/Online/Spectator/OnlineSpectatorClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ private void load(IAPIProvider api)
connection.On<int, FrameDataBundle>(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames);
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying);
connection.On<int, long>(nameof(ISpectatorClient.UserScoreProcessed), ((ISpectatorClient)this).UserScoreProcessed);
connection.On<SpectatorUser[]>(nameof(ISpectatorClient.UserStartedWatching), ((ISpectatorClient)this).UserStartedWatching);
connection.On<int>(nameof(ISpectatorClient.UserEndedWatching), ((ISpectatorClient)this).UserEndedWatching);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested);
};

Expand Down
36 changes: 35 additions & 1 deletion osu.Game/Online/Spectator/SpectatorClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Development;
Expand Down Expand Up @@ -36,10 +37,16 @@ public abstract partial class SpectatorClient : Component, ISpectatorClient
public abstract IBindable<bool> IsConnected { get; }

/// <summary>
/// The states of all users currently being watched.
/// The states of all users currently being watched by the local user.
/// </summary>
[UsedImplicitly] // Marked virtual due to mock use in testing
public virtual IBindableDictionary<int, SpectatorState> WatchedUserStates => watchedUserStates;

/// <summary>
/// All users who are currently watching the local user.
/// </summary>
public IBindableList<SpectatorUser> WatchingUsers => watchingUsers;

/// <summary>
/// A global list of all players currently playing.
/// </summary>
Expand All @@ -53,6 +60,7 @@ public abstract partial class SpectatorClient : Component, ISpectatorClient
/// <summary>
/// Called whenever new frames arrive from the server.
/// </summary>
[UsedImplicitly] // Marked virtual due to mock use in testing
public virtual event Action<int, FrameDataBundle>? OnNewFrames;

/// <summary>
Expand Down Expand Up @@ -82,6 +90,7 @@ public abstract partial class SpectatorClient : Component, ISpectatorClient

private readonly BindableDictionary<int, SpectatorState> watchedUserStates = new BindableDictionary<int, SpectatorState>();

private readonly BindableList<SpectatorUser> watchingUsers = new BindableList<SpectatorUser>();
private readonly BindableList<int> playingUsers = new BindableList<int>();
private readonly SpectatorState currentState = new SpectatorState();

Expand Down Expand Up @@ -127,6 +136,7 @@ private void load()
{
playingUsers.Clear();
watchedUserStates.Clear();
watchingUsers.Clear();
}
}), true);
}
Expand Down Expand Up @@ -179,6 +189,30 @@ Task ISpectatorClient.UserScoreProcessed(int userId, long scoreId)
return Task.CompletedTask;
}

Task ISpectatorClient.UserStartedWatching(SpectatorUser[] users)
{
Schedule(() =>
{
foreach (var user in users)
{
if (!watchingUsers.Contains(user))
watchingUsers.Add(user);
}
});

return Task.CompletedTask;
}

Task ISpectatorClient.UserEndedWatching(int userId)
{
Schedule(() =>
{
watchingUsers.RemoveAll(u => u.OnlineID == userId);
});

return Task.CompletedTask;
}

Task IStatefulUserHubClient.DisconnectRequested()
{
Schedule(() => DisconnectInternal());
Expand Down
39 changes: 39 additions & 0 deletions osu.Game/Online/Spectator/SpectatorUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using MessagePack;
using osu.Game.Users;

namespace osu.Game.Online.Spectator
{
[Serializable]
[MessagePackObject]
public class SpectatorUser : IUser, IEquatable<SpectatorUser>
{
[Key(0)]
public int OnlineID { get; set; }

[Key(1)]
public string Username { get; set; } = string.Empty;

[IgnoreMember]
public CountryCode CountryCode => CountryCode.Unknown;

[IgnoreMember]
public bool IsBot => false;

public bool Equals(SpectatorUser? other)
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;

return OnlineID == other.OnlineID;
}

public override bool Equals(object? obj) => Equals(obj as SpectatorUser);

// ReSharper disable once NonReadonlyMemberInGetHashCode
public override int GetHashCode() => OnlineID;
}
}
Loading
Loading