1
0
mirror of https://github.com/ppy/osu.git synced 2026-06-07 18:04:02 +08:00
Files
osu-lazer/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs
T
2025-11-14 19:20:56 +09:00

380 lines
14 KiB
C#

// 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.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
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, BeatmapSelectPanel> panelLookup = new Dictionary<long, BeatmapSelectPanel>();
private readonly PanelGridContainer panelGridContainer;
private readonly Container<BeatmapSelectPanel> 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? randomRevealSample;
private Sample? resultSample;
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<BeatmapSelectPanel>
{
RelativeSizeAxes = Axes.Both,
Masking = true,
},
};
// Special item denoting a random selection.
AddItem(new MultiplayerPlaylistItem { ID = -1 });
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
for (int i = 0; i < spinSamples.Length; i++)
spinSamples[i] = audio.Samples.Get($@"Multiplayer/Matchmaking/Selection/roulette-{i}");
randomRevealSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Selection/random-reveal");
resultSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Selection/roulette-result");
swooshSample = audio.Samples.Get(@"SongSelect/options-pop-out");
}
protected override void LoadComplete()
{
base.LoadComplete();
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);
}
});
}
public void AddItem(MultiplayerPlaylistItem item)
{
var panel = panelLookup[item.ID] = new BeatmapSelectPanel(item)
{
AllowSelection = allowSelection,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Action = i => ItemSelected?.Invoke(i),
};
panelGridContainer.Add(panel);
panelGridContainer.SetLayoutPosition(panel, (float)item.StarRating);
}
public void SetUserSelection(APIUser user, long itemId, bool selected)
{
if (!panelLookup.TryGetValue(itemId, out var panel))
return;
if (selected)
panel.AddUser(user);
else
panel.RemoveUser(user);
}
public void RevealRandomItem(MultiplayerPlaylistItem item)
{
if (!panelLookup.TryGetValue(-1, out var panel))
return;
panel.DisplayItem(item);
randomRevealSample?.Play();
}
public void RollAndDisplayFinalBeatmap(long[] candidateItemIds, long finalItemId)
{
Debug.Assert(candidateItemIds.Length >= 1);
Debug.Assert(candidateItemIds.Contains(finalItemId));
Debug.Assert(panelLookup.ContainsKey(finalItemId));
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(finalItemId));
}
else
{
this.Delay(ARRANGE_DELAY)
.Schedule(() => ArrangeItemsForRollAnimation())
.Delay(arrange_duration)
.Schedule(() => PlayRollAnimation(finalItemId, roll_duration))
.Delay(roll_duration + present_beatmap_delay)
.Schedule(() => PresentRolledBeatmap(finalItemId));
}
}
internal void TransferCandidatePanelsToRollContainer(long[] candidateItemIds, double duration = hide_duration)
{
scroll.ScrollbarVisible = false;
panelGridContainer.LayoutDisabled = true;
var rng = new Random();
var remainingPanels = new List<BeatmapSelectPanel>();
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] * (BeatmapSelectPanel.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++;
BeatmapSelectPanel? 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 finalItem)
{
Debug.Assert(rollContainer.Children.Any(it => it.Item.ID == finalItem));
foreach (var panel in rollContainer.Children)
{
if (panel.Item.ID != finalItem)
{
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);
panel.ShowChosenBorder();
panel.MoveTo(Vector2.Zero, 1000, Easing.OutExpo)
.ScaleTo(1.5f, 1000, Easing.OutExpo);
resultSample?.Play();
});
}
}
internal void PresentUnanimouslyChosenBeatmap(long finalItem)
{
// TODO: display special animation in this case
PresentRolledBeatmap(finalItem);
}
private partial class PanelGridContainer : FillFlowContainer<BeatmapSelectPanel>
{
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)));
}
}
}
}