Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs
4913 views
// 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 System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Humanizer;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Screens;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Online.Matchmaking.Events;
using osu.Game.Online.Metadata;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results;
using osu.Game.Screens.Play;
using osu.Game.Users;
using osuTK;

namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match
{
    /// <summary>
    /// A panel used throughout matchmaking to represent a user, including local information like their
    /// rank and high level statistics in the matchmaking system.
    /// </summary>
    public partial class PlayerPanel : OsuClickableContainer, IHasContextMenu
    {
        private static readonly Vector2 size_horizontal = new Vector2(300, 100);
        private static readonly Vector2 size_vertical = new Vector2(150, 200);
        private static readonly Vector2 avatar_size = new Vector2(80);

        public readonly MultiplayerRoomUser RoomUser;

        /// <summary>
        /// Perform an action in addition to showing the user's profile.
        /// This should be used to perform auxiliary tasks and not as a primary action for clicking a user panel (to maintain a consistent UX).
        /// </summary>
        public new Action? Action;

        [Resolved]
        private MultiplayerClient client { get; set; } = null!;

        [Resolved]
        private IAPIProvider api { get; set; } = null!;

        [Resolved]
        private UserProfileOverlay? profileOverlay { get; set; }

        [Resolved]
        private ChannelManager? channelManager { get; set; }

        [Resolved]
        private ChatOverlay? chatOverlay { get; set; }

        [Resolved]
        private IDialogOverlay? dialogOverlay { get; set; }

        [Resolved]
        private OverlayColourProvider? colourProvider { get; set; }

        [Resolved]
        private IPerformFromScreenRunner? performer { get; set; }

        [Resolved]
        private OsuColour colours { get; set; } = null!;

        [Resolved]
        private MultiplayerClient? multiplayerClient { get; set; }

        [Resolved]
        private MetadataClient? metadataClient { get; set; }

        public readonly APIUser User;
        private readonly Action viewProfile;

        private OsuSpriteText rankText = null!;
        private OsuSpriteText scoreText = null!;

        private Drawable avatarPositionTarget = null!;
        private Drawable avatarJumpTarget = null!;
        private Drawable avatar = null!;
        private OsuSpriteText username = null!;

        private Container mainContent = null!;

        private Box solidBackgroundLayer = null!;
        private Drawable background = null!;

        private OsuSpriteText quitText = null!;
        private BufferedContainer backgroundQuitTarget = null!;
        private BufferedContainer avatarQuitTarget = null!;

        private Box downloadProgressBar = null!;

        private PlayerPanelDisplayMode displayMode = PlayerPanelDisplayMode.Horizontal;
        private bool hasQuit;

        private enum InteractionSampleType
        {
            PlayerJump,
            PlayerReJump,
            OtherPlayerJump,
        }

        private Dictionary<InteractionSampleType, Sample?> interactionSamples = new Dictionary<InteractionSampleType, Sample?>();
        private readonly Dictionary<InteractionSampleType, SampleChannel?> interactionSampleChannels = new Dictionary<InteractionSampleType, SampleChannel?>();
        private double samplePitch;
        private double? lastSamplePlayback;

        public PlayerPanel(MultiplayerRoomUser user)
            : base(HoverSampleSet.Button)
        {
            ArgumentNullException.ThrowIfNull(user.User);

            User = user.User;
            RoomUser = user;

            base.Action = viewProfile = () =>
            {
                Action?.Invoke();
                profileOverlay?.ShowUser(User);
            };
        }

        [BackgroundDependencyLoader]
        private void load(AudioManager audio)
        {
            Content.Masking = true;
            Content.CornerRadius = 10;
            Content.CornerExponent = 10;
            Content.Anchor = Anchor.Centre;
            Content.Origin = Anchor.Centre;

            Child = backgroundQuitTarget = new BufferedContainer
            {
                FrameBufferScale = new Vector2(1.5f),
                RelativeSizeAxes = Axes.Both,
                Children = new[]
                {
                    solidBackgroundLayer = new Box
                    {
                        RelativeSizeAxes = Axes.Both,
                        Colour = colourProvider?.Background5 ?? colours.Gray1
                    },
                    background = new UserCoverBackground
                    {
                        RelativeSizeAxes = Axes.Both,
                        Anchor = Anchor.Centre,
                        Origin = Anchor.Centre,
                        Colour = colours.Gray7,
                        User = User
                    },
                    new Container
                    {
                        Anchor = Anchor.Centre,
                        Origin = Anchor.Centre,
                        RelativeSizeAxes = Axes.Both,
                        Children = new Drawable[]
                        {
                            mainContent = new Container
                            {
                                Anchor = Anchor.Centre,
                                Origin = Anchor.Centre,
                                RelativeSizeAxes = Axes.Both,
                                Children = new[]
                                {
                                    quitText = new OsuSpriteText
                                    {
                                        Anchor = Anchor.Centre,
                                        Origin = Anchor.Centre,
                                        Text = "QUIT",
                                        Font = OsuFont.Default.With(weight: "Bold", size: 70),
                                        Rotation = -22.5f,
                                        Colour = OsuColour.Gray(0.3f),
                                        Blending = BlendingParameters.Additive
                                    },
                                    avatarPositionTarget = new Container
                                    {
                                        Origin = Anchor.Centre,
                                        Size = avatar_size,
                                        Child = avatarJumpTarget = new Container
                                        {
                                            Anchor = Anchor.BottomCentre,
                                            Origin = Anchor.BottomCentre,
                                            RelativeSizeAxes = Axes.Both,
                                            Child = avatar = new Container
                                            {
                                                Anchor = Anchor.Centre,
                                                Origin = Anchor.Centre,
                                                RelativeSizeAxes = Axes.Both,
                                                // Needs to be re-buffered as the avatar is proxied outside of the parent buffered container.
                                                Child = avatarQuitTarget = new BufferedContainer
                                                {
                                                    FrameBufferScale = new Vector2(1.5f),
                                                    RelativeSizeAxes = Axes.Both,
                                                    Child = new MatchmakingAvatar(User, isOwnUser: User.Id == api.LocalUser.Value.Id)
                                                    {
                                                        Anchor = Anchor.Centre,
                                                        Origin = Anchor.Centre,
                                                        RelativeSizeAxes = Axes.Both,
                                                        Size = Vector2.One
                                                    }
                                                }
                                            },
                                        }
                                    },
                                    rankText = new OsuSpriteText
                                    {
                                        Alpha = 0,
                                        Anchor = Anchor.BottomRight,
                                        Origin = Anchor.BottomCentre,
                                        Blending = BlendingParameters.Additive,
                                        Margin = new MarginPadding(4),
                                        Text = "-",
                                        Font = OsuFont.Style.Title.With(size: 55),
                                    },
                                    username = new TruncatingSpriteText
                                    {
                                        Alpha = 0,
                                        Anchor = Anchor.BottomCentre,
                                        Origin = Anchor.BottomCentre,
                                        Text = User.Username,
                                        Font = OsuFont.Style.Heading1,
                                        MaxWidth = 120
                                    },
                                    scoreText = new OsuSpriteText
                                    {
                                        Alpha = 0,
                                        Margin = new MarginPadding(10),
                                        Anchor = Anchor.BottomCentre,
                                        Origin = Anchor.BottomCentre,
                                        Font = OsuFont.Style.Heading2,
                                        Text = "0 pts"
                                    }
                                }
                            },
                            downloadProgressBar = new Box
                            {
                                Anchor = Anchor.BottomLeft,
                                Origin = Anchor.BottomLeft,
                                RelativeSizeAxes = Axes.X,
                                Size = new Vector2(0, 4),
                                Colour = colourProvider?.Content2 ?? colours.Gray3
                            }
                        }
                    }
                }
            };

            // Allow avatar to exist outside of masking for when it jumps around and stuff.
            AddInternal(avatar.CreateProxy());

            interactionSamples = new Dictionary<InteractionSampleType, Sample?>
            {
                { InteractionSampleType.PlayerJump, audio.Samples.Get(@"Multiplayer/Matchmaking/player-jump") },
                { InteractionSampleType.PlayerReJump, audio.Samples.Get(@"Multiplayer/Matchmaking/player-rejump") },
                { InteractionSampleType.OtherPlayerJump, audio.Samples.Get(@"Multiplayer/Matchmaking/player-jump-other") }
            };
        }

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

            updateLayout(true);

            client.MatchRoomStateChanged += onRoomStateChanged;
            client.MatchEvent += onMatchEvent;
            client.BeatmapAvailabilityChanged += onBeatmapAvailabilityChanged;

            onRoomStateChanged(client.Room!.MatchState);

            avatar.ScaleTo(0)
                  .ScaleTo(1, 500, Easing.OutElasticHalf)
                  .FadeIn(200);

            // pick a random pitch to be used by the player for duration of this session
            samplePitch = 0.75f + RNG.NextDouble(0f, 0.75f);
        }

        public PlayerPanelDisplayMode DisplayMode
        {
            get => displayMode;
            set
            {
                displayMode = value;
                if (IsLoaded)
                    updateLayout(false);
            }
        }

        public bool HasQuit
        {
            get => hasQuit;
            set
            {
                hasQuit = value;
                if (IsLoaded)
                    updateLayout(false);
            }
        }

        private bool horizontal => displayMode == PlayerPanelDisplayMode.Horizontal;

        private Vector2 avatarPosition
        {
            get
            {
                switch (displayMode)
                {
                    case PlayerPanelDisplayMode.AvatarOnly:
                        return avatar_size / 2;

                    case PlayerPanelDisplayMode.Horizontal:
                        return new Vector2(50);

                    case PlayerPanelDisplayMode.Vertical:
                        return new Vector2(75, 50);

                    default:
                        throw new ArgumentOutOfRangeException();
                }
            }
        }

        private void updateLayout(bool instant)
        {
            double duration = instant ? 0 : 1000;

            avatarPositionTarget.MoveTo(avatarPosition, duration, Easing.OutPow10);

            switch (displayMode)
            {
                case PlayerPanelDisplayMode.AvatarOnly:
                    rankText.Hide();
                    scoreText.Hide();
                    username.Hide();

                    background.FadeOut(200, Easing.OutQuint);
                    solidBackgroundLayer.FadeOut(200, Easing.OutQuint);

                    this.ResizeTo(avatar_size, duration, Easing.OutPow10);
                    break;

                case PlayerPanelDisplayMode.Horizontal:
                case PlayerPanelDisplayMode.Vertical:
                    background.FadeIn(200);
                    solidBackgroundLayer.FadeIn(200);

                    using (BeginDelayedSequence(100))
                    {
                        username.FadeIn(600);

                        using (BeginDelayedSequence(100))
                        {
                            scoreText.FadeIn(600);

                            using (BeginDelayedSequence(100))
                            {
                                rankText.FadeTo(1, 600);
                            }
                        }
                    }

                    this.ResizeTo(horizontal ? size_horizontal : size_vertical, duration, Easing.OutPow10);

                    rankText.MoveTo(horizontal ? new Vector2(-40, -20) : new Vector2(-70, 0), duration, Easing.OutPow10);
                    username.MoveTo(horizontal ? new Vector2(0, -46) : new Vector2(0, -86), duration, Easing.OutPow10);
                    scoreText.MoveTo(horizontal ? new Vector2(0, -16) : new Vector2(0, -56), duration, Easing.OutPow10);
                    quitText.MoveTo(horizontal ? new Vector2(40, 0) : new Vector2(0, 40), duration, Easing.OutPow10);
                    break;

                default:
                    throw new ArgumentOutOfRangeException();
            }

            // quit text doesn't fit on avataronly mode.
            if (HasQuit && displayMode != PlayerPanelDisplayMode.AvatarOnly)
                quitText.FadeIn(duration, Easing.OutPow10);
            else
                quitText.FadeOut(duration, Easing.OutPow10);

            if (HasQuit)
            {
                backgroundQuitTarget.GrayscaleTo(1, duration, Easing.OutPow10);
                avatarQuitTarget.GrayscaleTo(1, duration, Easing.OutPow10);
            }
            else
            {
                backgroundQuitTarget.GrayscaleTo(0, duration, Easing.OutPow10);
                avatarQuitTarget.GrayscaleTo(0, duration, Easing.OutPow10);
            }
        }

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

            // Not sure why this is required but it is.
            avatarQuitTarget.Alpha = Alpha;
        }

        protected override bool OnHover(HoverEvent e)
        {
            Content.ScaleTo(1.03f, 2000, Easing.OutPow10);
            mainContent.ScaleTo(1.03f, 2000, Easing.OutPow10);
            return base.OnHover(e);
        }

        protected override void OnHoverLost(HoverLostEvent e)
        {
            Content.ScaleTo(1f, 750, Easing.OutPow10);
            mainContent.ScaleTo(1, 750, Easing.OutPow10);

            mainContent.MoveTo(Vector2.Zero, 1250, Easing.OutPow10);
            avatarPositionTarget.MoveTo(avatarPosition, 1250, Easing.OutPow10);
            base.OnHoverLost(e);
        }

        protected override bool OnMouseMove(MouseMoveEvent e)
        {
            var offset = (avatarPositionTarget.ToLocalSpace(e.ScreenSpaceMousePosition) - avatarPositionTarget.DrawSize / 2) * 0.02f;

            mainContent.MoveTo(offset * 0.5f, 2000, Easing.OutPow10);
            avatarPositionTarget.MoveTo(avatarPosition + offset, 2000, Easing.OutPow10);
            return base.OnMouseMove(e);
        }

        private void onRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() =>
        {
            if (state is not MatchmakingRoomState matchmakingState)
                return;

            if (!matchmakingState.Users.UserDictionary.TryGetValue(User.Id, out MatchmakingUser? userScore))
                return;

            if (userScore.Placement == null)
                return;

            rankText.Text = userScore.Placement.Value.Ordinalize(CultureInfo.CurrentCulture);
            rankText.FadeColour(SubScreenResults.ColourForPlacement(userScore.Placement.Value));
            scoreText.Text = $"{userScore.Points} pts";
        });

        private int consecutiveJumps;

        private void onMatchEvent(MatchServerEvent e)
        {
            switch (e)
            {
                case MatchmakingAvatarActionEvent action:
                    if (action.UserId != RoomUser.UserID)
                        break;

                    switch (action.Action)
                    {
                        case MatchmakingAvatarAction.Jump:
                            var movement = avatarJumpTarget.Delay(0);
                            var scale = avatarJumpTarget.Delay(0);

                            // only increase height if the user jumps again while in a "jumped" state.
                            // this avoids building up large jumps from very quick spam, and adds a timing game.
                            bool isConsecutive = avatarJumpTarget.Y < 0;

                            if (isConsecutive)
                            {
                                consecutiveJumps++;

                                if (avatarJumpTarget.Y > 0)
                                    movement = movement.MoveToY(0);

                                movement = movement.MoveToY(5, 100, Easing.Out);
                                scale = scale.ScaleTo(new Vector2(1, 0.95f), 100, Easing.Out);
                            }
                            else
                            {
                                consecutiveJumps = 0;
                            }

                            float multiplier = 1 + 0.3f * Math.Min(10, consecutiveJumps);

                            movement.Then().MoveToY(-10 * multiplier, 200, Easing.Out)
                                    .Then().MoveToY(0, 200, Easing.In);

                            scale.Then().ScaleTo(new Vector2(1, 1.05f), 200, Easing.Out)
                                 .Then().ScaleTo(new Vector2(1, 0.95f), 200, Easing.In)
                                 .Then().ScaleTo(Vector2.One, 800, Easing.OutElastic);

                            // only play jump sample if panel is visible
                            if (Alpha > 0)
                                playJumpSample(isConsecutive);

                            break;
                    }

                    break;
            }
        }

        private void onBeatmapAvailabilityChanged(MultiplayerRoomUser user, BeatmapAvailability availability) => Scheduler.Add(() =>
        {
            if (!user.Equals(RoomUser))
                return;

            if (availability.State == DownloadState.Downloading)
                downloadProgressBar.FadeIn(200, Easing.OutPow10);
            else
                downloadProgressBar.FadeOut(200, Easing.OutPow10);

            downloadProgressBar.ResizeWidthTo(availability.DownloadProgress ?? 0, 200, Easing.OutPow10);
        });

        private void playJumpSample(bool rejumping)
        {
            bool isLocalUser = User.OnlineID == client.LocalUser?.UserID;

            if (isLocalUser)
                playInteractionSample(rejumping ? InteractionSampleType.PlayerReJump : InteractionSampleType.PlayerJump);
            else
                playInteractionSample(InteractionSampleType.OtherPlayerJump);
        }

        private void playInteractionSample(InteractionSampleType sampleType)
        {
            bool enoughTimePassedSinceLastPlayback = lastSamplePlayback == null || Time.Current - lastSamplePlayback.Value >= OsuGameBase.SAMPLE_DEBOUNCE_TIME;
            if (!enoughTimePassedSinceLastPlayback)
                return;

            Sample? targetSample = interactionSamples[sampleType];
            SampleChannel? targetChannel = interactionSampleChannels.GetValueOrDefault(sampleType);

            targetChannel?.Stop();
            targetChannel = targetSample?.GetChannel();

            if (targetChannel == null)
                return;

            float horizontalPos = BoundingBox.Centre.X / Parent!.ToLocalSpace(Parent!.ScreenSpaceDrawQuad).Width;
            // rescale balance from 0..1 to -1..1
            float balance = -1f + horizontalPos * 2f;

            targetChannel.Frequency.Value = samplePitch;
            targetChannel.Balance.Value = balance * OsuGameBase.SFX_STEREO_STRENGTH;
            targetChannel.Play();

            interactionSampleChannels[sampleType] = targetChannel;

            lastSamplePlayback = Time.Current;
        }

        protected override void Dispose(bool isDisposing)
        {
            base.Dispose(isDisposing);

            if (client.IsNotNull())
            {
                client.MatchRoomStateChanged -= onRoomStateChanged;
                client.MatchEvent -= onMatchEvent;
                client.BeatmapAvailabilityChanged -= onBeatmapAvailabilityChanged;
            }
        }

        public MenuItem[] ContextMenuItems
        {
            get
            {
                List<MenuItem> items = new List<MenuItem>
                {
                    new OsuMenuItem(ContextMenuStrings.ViewProfile, MenuItemType.Highlighted, viewProfile)
                };

                if (User.Equals(api.LocalUser.Value))
                    return items.ToArray();

                items.Add(new OsuMenuItem(UsersStrings.CardSendMessage, MenuItemType.Standard, () =>
                {
                    channelManager?.OpenPrivateChannel(User);
                    chatOverlay?.Show();
                }));

                items.Add(!isUserBlocked()
                    ? new OsuMenuItem(UsersStrings.BlocksButtonBlock, MenuItemType.Destructive, () => dialogOverlay?.Push(ConfirmBlockActionDialog.Block(User)))
                    : new OsuMenuItem(UsersStrings.BlocksButtonUnblock, MenuItemType.Standard, () => dialogOverlay?.Push(ConfirmBlockActionDialog.Unblock(User))));

                if (isUserOnline())
                {
                    items.Add(new OsuMenuItem(ContextMenuStrings.SpectatePlayer, MenuItemType.Standard, () =>
                    {
                        if (isUserOnline())
                            performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User)));
                    }));

                    if (canInviteUser())
                    {
                        items.Add(new OsuMenuItem(ContextMenuStrings.InvitePlayer, MenuItemType.Standard, () =>
                        {
                            if (canInviteUser())
                                multiplayerClient!.InvitePlayer(User.Id);
                        }));
                    }
                }

                return items.ToArray();

                bool isUserOnline() => metadataClient?.GetPresence(User.OnlineID) != null;
                bool canInviteUser() => isUserOnline() && multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true;
                bool isUserBlocked() => api.LocalUserState.Blocks.Any(b => b.TargetID == User.OnlineID);
            }
        }
    }

    public enum PlayerPanelDisplayMode
    {
        AvatarOnly,
        Horizontal,
        Vertical
    }
}