diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index c4ba6e5143..6ec071be2f 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -9,7 +9,7 @@ ] }, "nvika": { - "version": "3.0.0", + "version": "4.0.0", "commands": [ "nvika" ] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs new file mode 100644 index 0000000000..9a54de1459 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . 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 spectators = new BindableList(); + private readonly Bindable localUserPlayingState = new Bindable(); + + 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); + } + } +} diff --git a/osu.Game/Graphics/Containers/OsuHoverContainer.cs b/osu.Game/Graphics/Containers/OsuHoverContainer.cs index 3b5e48d23e..e396eb6ec9 100644 --- a/osu.Game/Graphics/Containers/OsuHoverContainer.cs +++ b/osu.Game/Graphics/Containers/OsuHoverContainer.cs @@ -15,9 +15,11 @@ namespace osu.Game.Graphics.Containers { 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 EffectTargets => new[] { Content }; @@ -67,18 +69,18 @@ namespace osu.Game.Graphics.Containers [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)); } } diff --git a/osu.Game/Localisation/HUD/SpectatorListStrings.cs b/osu.Game/Localisation/HUD/SpectatorListStrings.cs new file mode 100644 index 0000000000..8d82250526 --- /dev/null +++ b/osu.Game/Localisation/HUD/SpectatorListStrings.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . 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"; + + /// + /// "Spectators ({0})" + /// + public static LocalisableString SpectatorCount(int arg0) => new TranslatableString(getKey(@"spectator_count"), @"Spectators ({0})", arg0); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Online/Chat/DrawableLinkCompiler.cs b/osu.Game/Online/Chat/DrawableLinkCompiler.cs index fa107a0e43..e4baeb4838 100644 --- a/osu.Game/Online/Chat/DrawableLinkCompiler.cs +++ b/osu.Game/Online/Chat/DrawableLinkCompiler.cs @@ -56,7 +56,7 @@ namespace osu.Game.Online.Chat [BackgroundDependencyLoader] private void load(OsuColour colours) { - IdleColour = overlayColourProvider?.Light2 ?? colours.Blue; + IdleColour ??= overlayColourProvider?.Light2 ?? colours.Blue; } protected override IEnumerable EffectTargets => Parts; diff --git a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs index af78d62789..c4425643fd 100644 --- a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs @@ -1,6 +1,7 @@ // 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.Bindables; @@ -200,16 +201,19 @@ namespace osu.Game.Overlays.Profile.Header.Components 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 diff --git a/osu.Game/Screens/Play/HUD/SpectatorList.cs b/osu.Game/Screens/Play/HUD/SpectatorList.cs new file mode 100644 index 0000000000..438aa61d9d --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SpectatorList.cs @@ -0,0 +1,242 @@ +// 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.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 Spectators { get; } = new BindableList(); + public Bindable UserPlayingState { get; } = new Bindable(); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))] + public Bindable Font { get; } = new Bindable(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 spectatorsFlow = null!; + private DrawablePool 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 + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + } + } + }, + pool = new DrawablePool(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 Current { get; } = new Bindable(); + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable 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; + } + } +}