Skip to content

Implement spectator list display #31526

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

Merged
merged 8 commits into from
Jan 17, 2025
53 changes: 53 additions & 0 deletions osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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.Threading;
using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;

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
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Spectators = { BindTarget = spectators },
UserPlayingState = { BindTarget = localUserPlayingState }
});

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

AddRepeatStep("add a user", () =>
{
int id = Interlocked.Increment(ref counter);
spectators.Add(new SpectatorList.Spectator(id, $"User {id}"));
}, 10);

AddRepeatStep("remove random user", () => spectators.RemoveAt(RNG.Next(0, spectators.Count)), 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);
}
}
}
16 changes: 9 additions & 7 deletions osu.Game/Graphics/Containers/OsuHoverContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ public partial class OsuHoverContainer : OsuClickableContainer
{
protected const float FADE_DURATION = 500;

protected Color4 HoverColour;
public Color4? HoverColour { get; set; }
private Color4 fallbackHoverColour;

protected Color4 IdleColour = Color4.White;
public Color4? IdleColour { get; set; }
private Color4 fallbackIdleColour;

protected virtual IEnumerable<Drawable> EffectTargets => new[] { Content };

Expand Down Expand Up @@ -67,18 +69,18 @@ protected override void OnHoverLost(HoverLostEvent e)
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
if (HoverColour == default)
HoverColour = colours.Yellow;
fallbackHoverColour = colours.Yellow;
fallbackIdleColour = Color4.White;
}

protected override void LoadComplete()
{
base.LoadComplete();
EffectTargets.ForEach(d => d.FadeColour(IdleColour));
EffectTargets.ForEach(d => d.FadeColour(IdleColour ?? fallbackIdleColour));
}

private void fadeIn() => EffectTargets.ForEach(d => d.FadeColour(HoverColour, FADE_DURATION, Easing.OutQuint));
private void fadeIn() => EffectTargets.ForEach(d => d.FadeColour(HoverColour ?? fallbackHoverColour, FADE_DURATION, Easing.OutQuint));

private void fadeOut() => EffectTargets.ForEach(d => d.FadeColour(IdleColour, FADE_DURATION, Easing.OutQuint));
private void fadeOut() => EffectTargets.ForEach(d => d.FadeColour(IdleColour ?? fallbackIdleColour, FADE_DURATION, Easing.OutQuint));
}
}
19 changes: 19 additions & 0 deletions osu.Game/Localisation/HUD/SpectatorListStrings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// 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 osu.Framework.Localisation;

namespace osu.Game.Localisation.HUD
{
public static class SpectatorListStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.SpectatorList";

/// <summary>
/// "Spectators ({0})"
/// </summary>
public static LocalisableString SpectatorCount(int arg0) => new TranslatableString(getKey(@"spectator_count"), @"Spectators ({0})", arg0);

private static string getKey(string key) => $@"{prefix}:{key}";
}
}
2 changes: 1 addition & 1 deletion osu.Game/Online/Chat/DrawableLinkCompiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public DrawableLinkCompiler(IEnumerable<Drawable> parts)
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
IdleColour = overlayColourProvider?.Light2 ?? colours.Blue;
IdleColour ??= overlayColourProvider?.Light2 ?? colours.Blue;
}

protected override IEnumerable<Drawable> EffectTargets => Parts;
Expand Down
10 changes: 7 additions & 3 deletions osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs
Original file line number Diff line number Diff line change
@@ -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 osu.Framework.Allocation;
using osu.Framework.Bindables;
Expand Down Expand Up @@ -200,16 +201,19 @@ private void updateColor()

case FriendStatus.NotMutual:
IdleColour = colour.Green.Opacity(0.7f);
HoverColour = IdleColour.Lighten(0.1f);
HoverColour = IdleColour.Value.Lighten(0.1f);
break;

case FriendStatus.Mutual:
IdleColour = colour.Pink.Opacity(0.7f);
HoverColour = IdleColour.Lighten(0.1f);
HoverColour = IdleColour.Value.Lighten(0.1f);
break;

default:
throw new ArgumentOutOfRangeException();
}

EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour : IdleColour, FADE_DURATION, Easing.OutQuint));
EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour.Value : IdleColour.Value, FADE_DURATION, Easing.OutQuint));
}

private enum FriendStatus
Expand Down
242 changes: 242 additions & 0 deletions osu.Game/Screens/Play/HUD/SpectatorList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Chat;
using osu.Game.Users;
using osu.Game.Localisation.HUD;
using osu.Game.Localisation.SkinComponents;
using osuTK.Graphics;

namespace osu.Game.Screens.Play.HUD
{
public partial class SpectatorList : CompositeDrawable
{
private const int max_spectators_displayed = 10;

public BindableList<Spectator> Spectators { get; } = new BindableList<Spectator>();
public Bindable<LocalUserPlayingState> UserPlayingState { get; } = new Bindable<LocalUserPlayingState>();

[SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))]
public Bindable<Typeface> Font { get; } = new Bindable<Typeface>(Typeface.Torus);

[SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour), nameof(SkinnableComponentStrings.TextColourDescription))]
public BindableColour4 HeaderColour { get; } = new BindableColour4(Colour4.White);

protected OsuSpriteText Header { get; private set; } = null!;

private FillFlowContainer mainFlow = null!;
private FillFlowContainer<SpectatorListEntry> spectatorsFlow = null!;
private DrawablePool<SpectatorListEntry> pool = null!;

[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
AutoSizeAxes = Axes.Y;

InternalChildren = new Drawable[]
{
mainFlow = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
Header = new OsuSpriteText
{
Colour = colours.Blue0,
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold),
},
spectatorsFlow = new FillFlowContainer<SpectatorListEntry>
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
}
}
},
pool = new DrawablePool<SpectatorListEntry>(max_spectators_displayed),
};

HeaderColour.Value = Header.Colour;
}

protected override void LoadComplete()
{
base.LoadComplete();

Spectators.BindCollectionChanged(onSpectatorsChanged, true);
UserPlayingState.BindValueChanged(_ => updateVisibility());

Font.BindValueChanged(_ => updateAppearance());
HeaderColour.BindValueChanged(_ => updateAppearance(), true);
FinishTransforms(true);

this.FadeInFromZero(200, Easing.OutQuint);
}

private void onSpectatorsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
{
for (int i = 0; i < e.NewItems!.Count; i++)
{
var spectator = (Spectator)e.NewItems![i]!;
int index = Math.Max(e.NewStartingIndex, 0) + i;

if (index >= max_spectators_displayed)
break;

addNewSpectatorToList(index, spectator);
}

break;
}

case NotifyCollectionChangedAction.Remove:
{
spectatorsFlow.RemoveAll(entry => e.OldItems!.Contains(entry.Current.Value), false);

for (int i = 0; i < spectatorsFlow.Count; i++)
spectatorsFlow.SetLayoutPosition(spectatorsFlow[i], i);

if (Spectators.Count >= max_spectators_displayed && spectatorsFlow.Count < max_spectators_displayed)
{
for (int i = spectatorsFlow.Count; i < max_spectators_displayed; i++)
addNewSpectatorToList(i, Spectators[i]);
}

break;
}

case NotifyCollectionChangedAction.Reset:
{
spectatorsFlow.Clear(false);
break;
}

default:
throw new NotSupportedException();
}

Header.Text = SpectatorListStrings.SpectatorCount(Spectators.Count).ToUpper();
updateVisibility();

for (int i = 0; i < spectatorsFlow.Count; i++)
{
spectatorsFlow[i].Colour = i < max_spectators_displayed - 1
? Color4.White
: ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0));
}
}

private void addNewSpectatorToList(int i, Spectator spectator)
{
var entry = pool.Get(entry =>
{
entry.Current.Value = spectator;
entry.UserPlayingState = UserPlayingState;
});

spectatorsFlow.Insert(i, entry);
}

private void updateVisibility()
{
mainFlow.FadeTo(Spectators.Count > 0 && UserPlayingState.Value != LocalUserPlayingState.NotPlaying ? 1 : 0, 250, Easing.OutQuint);
}

private void updateAppearance()
{
Header.Font = OsuFont.GetFont(Font.Value, 12, FontWeight.Bold);
Header.Colour = HeaderColour.Value;

Width = Header.DrawWidth;
}

private partial class SpectatorListEntry : PoolableDrawable
{
public Bindable<Spectator> Current { get; } = new Bindable<Spectator>();

private readonly BindableWithCurrent<LocalUserPlayingState> current = new BindableWithCurrent<LocalUserPlayingState>();

public Bindable<LocalUserPlayingState> UserPlayingState
{
get => current.Current;
set => current.Current = value;
}

private OsuSpriteText username = null!;
private DrawableLinkCompiler? linkCompiler;

[Resolved]
private OsuGame? game { get; set; }

[BackgroundDependencyLoader]
private void load()
{
AutoSizeAxes = Axes.Both;

InternalChildren = new Drawable[]
{
username = new OsuSpriteText(),
};
}

protected override void LoadComplete()
{
base.LoadComplete();
UserPlayingState.BindValueChanged(_ => updateEnabledState());
Current.BindValueChanged(_ => updateState(), true);
}

protected override void PrepareForUse()
{
base.PrepareForUse();

username.MoveToX(10)
.Then()
.MoveToX(0, 400, Easing.OutQuint);

this.FadeInFromZero(400, Easing.OutQuint);
}

private void updateState()
{
username.Text = Current.Value.Username;
linkCompiler?.Expire();
AddInternal(linkCompiler = new DrawableLinkCompiler([username])
{
IdleColour = Colour4.White,
Action = () => game?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, Current.Value)),
});
updateEnabledState();
}

private void updateEnabledState()
{
if (linkCompiler != null)
linkCompiler.Enabled.Value = UserPlayingState.Value != LocalUserPlayingState.Playing;
}
}

public record Spectator(int OnlineID, string Username) : IUser
{
public CountryCode CountryCode => CountryCode.Unknown;
public bool IsBot => false;
}
}
}
Loading