Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs
5075 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.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Toolkit.HighPerformance;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Transforms;
using osu.Framework.Utils;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osuTK;

namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect
{
    public partial class BeatmapSelectGrid : CompositeDrawable
    {
        public const double ARRANGE_DELAY = 200;

        private const double hide_duration = 800;
        private const double arrange_duration = 1000;
        private const double roll_duration = 4000;
        private const double present_beatmap_delay = 1200;
        private const float panel_spacing = 4;

        public event Action<MultiplayerPlaylistItem>? ItemSelected;

        private readonly Dictionary<long, MatchmakingSelectPanel> panelLookup = new Dictionary<long, MatchmakingSelectPanel>();
        private readonly Dictionary<long, MatchmakingPlaylistItem> playlistItems = new Dictionary<long, MatchmakingPlaylistItem>();
        private MatchmakingSelectPanelRandom randomPanel = null!;

        private readonly PanelGridContainer panelGridContainer;
        private readonly Container<MatchmakingSelectPanel> rollContainer;
        private readonly OsuScrollContainer scroll;

        private bool allowSelection = true;

        private readonly Sample?[] spinSamples = new Sample?[5];
        private static readonly int[] spin_sample_sequence = [0, 1, 2, 3, 4, 2, 3, 4];
        private Sample? swooshSample;
        private double? lastSamplePlayback;

        public BeatmapSelectGrid()
        {
            InternalChildren = new Drawable[]
            {
                scroll = new OsuScrollContainer
                {
                    RelativeSizeAxes = Axes.Both,
                    ScrollbarVisible = false,
                    Child = panelGridContainer = new PanelGridContainer
                    {
                        RelativeSizeAxes = Axes.X,
                        AutoSizeAxes = Axes.Y,
                        Padding = new MarginPadding(20),
                        Spacing = new Vector2(panel_spacing)
                    },
                },
                rollContainer = new Container<MatchmakingSelectPanel>
                {
                    RelativeSizeAxes = Axes.Both,
                    Masking = true,
                },
            };
        }

        [BackgroundDependencyLoader]
        private void load(AudioManager audio)
        {
            for (int i = 0; i < spinSamples.Length; i++)
                spinSamples[i] = audio.Samples.Get($@"Multiplayer/Matchmaking/Selection/roulette-{i}");

            swooshSample = audio.Samples.Get(@"SongSelect/options-pop-out");
        }

        public void AddItems(IEnumerable<MatchmakingPlaylistItem> items)
        {
            foreach (var item in items)
            {
                playlistItems[item.ID] = item;

                var panel = panelLookup[item.ID] = new MatchmakingSelectPanelBeatmap(item)
                {
                    AllowSelection = allowSelection,
                    Anchor = Anchor.TopCentre,
                    Origin = Anchor.TopCentre,
                    Action = i => ItemSelected?.Invoke(i),
                    Depth = -(float)item.PlaylistItem.StarRating
                };

                panelGridContainer.Add(panel);
                panelGridContainer.SetLayoutPosition(panel, (float)panel.Item.StarRating);
            }

            panelLookup[-1] = randomPanel = new MatchmakingSelectPanelRandom(new MultiplayerPlaylistItem { ID = -1 })
            {
                AllowSelection = allowSelection,
                Anchor = Anchor.TopCentre,
                Origin = Anchor.TopCentre,
                Action = i => ItemSelected?.Invoke(i),
            };
            panelGridContainer.Add(randomPanel);
            panelGridContainer.SetLayoutPosition(randomPanel, float.MinValue);

            const double enter_duration = 500;

            // the scroll container has a 1 frame delay until it receives the correct height for the scrollable area which leads to the scrollbar resizing awkwardly
            // if we wait until the panels have entered we get to avoid having to see that and the scrollbar it will appear synchronized with the rest of the content as a bonus
            Scheduler.AddDelayed(() => scroll.ScrollbarVisible = true, enter_duration);

            SchedulerAfterChildren.Add(() =>
            {
                foreach (var panel in panelGridContainer)
                {
                    double delay = panel.Y / 3;

                    panel.FadeInAndEnterFromBelow(duration: enter_duration, delay: delay);
                }

                panelsLoaded.SetResult();
            });
        }

        public void SetUserSelection(APIUser user, long itemId, bool selected) => whenPanelsLoaded(() =>
        {
            if (!panelLookup.TryGetValue(itemId, out var panel))
                return;

            if (selected)
                panel.AddUser(user);
            else
                panel.RemoveUser(user);
        });

        public void RollAndDisplayFinalBeatmap(long[] candidateItemIds, long candidateItemId, long gameplayItemId) => whenPanelsLoaded(() =>
        {
            Debug.Assert(candidateItemIds.Length >= 1);
            Debug.Assert(candidateItemIds.Contains(candidateItemId));
            Debug.Assert(panelLookup.ContainsKey(candidateItemId));
            Debug.Assert(candidateItemIds.All(id => panelLookup.ContainsKey(id)));

            allowSelection = false;

            TransferCandidatePanelsToRollContainer(candidateItemIds);

            if (candidateItemIds.Length == 1)
            {
                this.Delay(ARRANGE_DELAY)
                    .Schedule(() => ArrangeItemsForRollAnimation())
                    .Delay(arrange_duration + present_beatmap_delay)
                    .Schedule(() => PresentUnanimouslyChosenBeatmap(candidateItemId, gameplayItemId));
            }
            else
            {
                this.Delay(ARRANGE_DELAY)
                    .Schedule(() => ArrangeItemsForRollAnimation())
                    .Delay(arrange_duration)
                    .Schedule(() => PlayRollAnimation(candidateItemId, roll_duration))
                    .Delay(roll_duration + present_beatmap_delay)
                    .Schedule(() => PresentRolledBeatmap(candidateItemId, gameplayItemId));
            }
        });

        internal void TransferCandidatePanelsToRollContainer(long[] candidateItemIds, double duration = hide_duration)
        {
            scroll.ScrollbarVisible = false;
            panelGridContainer.LayoutDisabled = true;

            var rng = new Random();

            var remainingPanels = new List<MatchmakingSelectPanel>();

            foreach (var panel in panelGridContainer.Children.ToArray())
            {
                panel.AllowSelection = false;

                if (!candidateItemIds.Contains(panel.Item.ID))
                {
                    panel.PopOutAndExpire(duration: duration / 2, delay: rng.NextDouble() * duration / 2);
                    continue;
                }

                remainingPanels.Add(panel);
            }

            rng.Shuffle(remainingPanels.AsSpan());

            foreach (var panel in remainingPanels)
            {
                var position = panel.ScreenSpaceDrawQuad.Centre;

                panelGridContainer.Remove(panel, false);

                panel.Anchor = panel.Origin = Anchor.Centre;
                panel.Position = rollContainer.ToLocalSpace(position) - rollContainer.ChildSize / 2;

                rollContainer.Add(panel);
            }
        }

        internal void ArrangeItemsForRollAnimation(double duration = arrange_duration, double stagger = 30)
        {
            var positions = calculateLayoutPositionsForRollAnimation(rollContainer.Children.Count);

            Debug.Assert(positions.Length == rollContainer.Children.Count);

            for (int i = 0; i < positions.Length; i++)
            {
                var panel = rollContainer.Children[i];

                var position = positions[i] * (MatchmakingSelectPanel.SIZE + new Vector2(panel_spacing));

                panel.MoveTo(position, duration + stagger * i, new SplitEasingFunction(Easing.InCubic, Easing.OutExpo, 0.3f));

                Scheduler.AddDelayed(() =>
                {
                    var chan = swooshSample?.GetChannel();
                    if (chan == null) return;

                    chan.Frequency.Value = 1.25f - RNG.NextDouble(0.5f);
                    chan.Play();
                }, stagger * i);
            }
        }

        private static Vector2[] calculateLayoutPositionsForRollAnimation(int panelCount)
        {
            if (panelCount == 1)
                return new[] { Vector2.Zero };

            // goal is to get the positions arranged in clockwise order, with the top-left position being the first one
            // to keep things simple the positions are first inserted in the order: right row, optional bottom center panel, left row backwards
            // then the positions get shifted by 1 to move the top-left position into the first spot

            bool hasCenterPanel = panelCount % 2 == 1;
            int rowCount = (panelCount + 1) / 2;
            int outerRowCount = hasCenterPanel ? rowCount - 1 : rowCount;

            float yOffset = -(rowCount - 1f) / 2;

            var positions = new Vector2[panelCount];

            for (int row = 0; row < outerRowCount; row++)
            {
                positions[row] = new Vector2(0.5f, row + yOffset);
            }

            if (hasCenterPanel)
            {
                int centerIndex = panelCount / 2;

                positions[centerIndex] = new Vector2(0, outerRowCount + yOffset);
            }

            for (int row = 0; row < outerRowCount; row++)
            {
                int index = positions.Length - 1 - row;

                positions[index] = new Vector2(-0.5f, row + yOffset);
            }

            return positions.TakeLast(1).Concat(positions.SkipLast(1)).ToArray();
        }

        internal void PlayRollAnimation(long finalItem, double duration = roll_duration)
        {
            const int minimum_steps = 20;

            int finalItemIndex = rollContainer.Children
                                              .Select(it => it.Item.ID)
                                              .ToImmutableList()
                                              .IndexOf(finalItem);

            Debug.Assert(finalItemIndex >= 0);

            int numSteps = minimum_steps;
            while ((numSteps - 1) % rollContainer.Children.Count != finalItemIndex)
                numSteps++;

            MatchmakingSelectPanel? lastPanel = null;

            for (int i = 0; i < numSteps; i++)
            {
                float progress = ((float)i) / (numSteps - 1);

                double delay = Math.Pow(progress, 2.5) * duration;
                var panel = rollContainer.Children[i % rollContainer.Children.Count];

                int ii = i;
                Scheduler.AddDelayed(() =>
                {
                    lastPanel?.HideBorder();
                    panel.ShowBorder();

                    if (lastSamplePlayback == null || Time.Current - lastSamplePlayback > OsuGameBase.SAMPLE_DEBOUNCE_TIME)
                    {
                        int sequenceIdx = ii % spin_sample_sequence.Length;
                        spinSamples[spin_sample_sequence[sequenceIdx]]?.Play();
                        lastSamplePlayback = Time.Current;
                    }

                    lastPanel = panel;
                }, delay);
            }
        }

        internal void PresentRolledBeatmap(long candidateItem, long gameplayItem)
        {
            Debug.Assert(rollContainer.Children.Any(it => it.Item.ID == candidateItem));
            Debug.Assert(playlistItems.ContainsKey(gameplayItem));

            foreach (var panel in rollContainer.Children)
            {
                if (panel.Item.ID != candidateItem)
                {
                    panel.FadeOut(200);
                    panel.PopOutAndExpire(easing: Easing.InQuad);
                    continue;
                }

                // if we changed child depth without scheduling we'd change the order of the panels while iterating
                Schedule(() =>
                {
                    rollContainer.ChangeChildDepth(panel, float.MinValue);

                    var item = playlistItems[gameplayItem];

                    panel.PresentAsChosenBeatmap(item);
                });
            }
        }

        internal void PresentUnanimouslyChosenBeatmap(long candidateItem, long gameplayItem)
        {
            // TODO: display special animation in this case

            PresentRolledBeatmap(candidateItem, gameplayItem);
        }

        private readonly TaskCompletionSource panelsLoaded = new TaskCompletionSource();

        private void whenPanelsLoaded(Action action) => Task.Run(async () =>
        {
            await panelsLoaded.Task.ConfigureAwait(false);
            Schedule(action);
        });

        private partial class PanelGridContainer : FillFlowContainer<MatchmakingSelectPanel>
        {
            public bool LayoutDisabled;

            protected override IEnumerable<Vector2> ComputeLayoutPositions()
            {
                if (LayoutDisabled)
                    return FlowingChildren.Select(c => c.Position);

                return base.ComputeLayoutPositions();
            }
        }

        private readonly struct SplitEasingFunction(DefaultEasingFunction easeIn, DefaultEasingFunction easeOut, float ratio) : IEasingFunction
        {
            public SplitEasingFunction(Easing easeIn, Easing easeOut, float ratio = 0.5f)
                : this(new DefaultEasingFunction(easeIn), new DefaultEasingFunction(easeOut), ratio)
            {
            }

            public double ApplyEasing(double time)
            {
                if (time < ratio)
                    return easeIn.ApplyEasing(time / ratio) * ratio;

                return double.Lerp(ratio, 1, easeOut.ApplyEasing((time - ratio) / (1 - ratio)));
            }
        }
    }
}