1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-22 15:27:26 +08:00
osu-lazer/osu.Game/Screens/Select/BeatmapCarousel.cs

544 lines
20 KiB
C#
Raw Normal View History

// Copyright (c) 2007-2017 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
2016-12-06 17:56:20 +08:00
using OpenTK;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Configuration;
using osu.Framework.Input;
using OpenTK.Input;
using osu.Framework.MathUtils;
using System.Diagnostics;
using System.Threading.Tasks;
using osu.Framework.Allocation;
2017-12-12 16:48:38 +08:00
using osu.Framework.Caching;
using osu.Framework.Threading;
using osu.Framework.Configuration;
2017-12-14 19:40:58 +08:00
using osu.Framework.Extensions.IEnumerableExtensions;
2017-07-26 12:22:46 +08:00
using osu.Game.Beatmaps;
using osu.Game.Graphics.Containers;
2017-09-14 14:41:32 +08:00
using osu.Game.Graphics.Cursor;
2017-12-12 16:48:38 +08:00
using osu.Game.Screens.Select.Carousel;
2016-11-25 17:14:56 +08:00
namespace osu.Game.Screens.Select
{
public class BeatmapCarousel : OsuScrollContainer
{
2017-12-13 18:56:16 +08:00
/// <summary>
/// Triggered when the <see cref="BeatmapSets"/> loaded change and are completely loaded.
2017-12-13 18:56:16 +08:00
/// </summary>
public Action BeatmapSetsChanged;
2017-12-13 18:56:16 +08:00
/// <summary>
/// The currently selected beatmap.
/// </summary>
2017-12-12 16:48:38 +08:00
public BeatmapInfo SelectedBeatmap => selectedBeatmap?.Beatmap;
private CarouselBeatmap selectedBeatmap => selectedBeatmapSet?.Beatmaps.FirstOrDefault(s => s.State == CarouselItemState.Selected);
/// <summary>
/// The currently selected beatmap set.
/// </summary>
public BeatmapSetInfo SelectedBeatmapSet => selectedBeatmapSet?.BeatmapSet;
2017-12-14 12:49:43 +08:00
private CarouselBeatmapSet selectedBeatmapSet => beatmapSets.FirstOrDefault(s => s.State == CarouselItemState.Selected);
2017-12-13 18:56:16 +08:00
/// <summary>
/// Raised when the <see cref="SelectedBeatmap"/> is changed.
/// </summary>
public Action<BeatmapInfo> SelectionChanged;
2017-12-13 18:56:16 +08:00
public override bool HandleInput => AllowSelection;
2017-12-14 19:40:58 +08:00
private IEnumerable<CarouselBeatmapSet> beatmapSets => root.Children?.OfType<CarouselBeatmapSet>() ?? new CarouselBeatmapSet[] { };
2017-12-14 12:49:43 +08:00
public IEnumerable<BeatmapSetInfo> BeatmapSets
{
2017-12-14 12:49:43 +08:00
get { return beatmapSets.Select(g => g.BeatmapSet); }
set
{
2017-12-12 16:48:38 +08:00
List<CarouselBeatmapSet> newSets = null;
CarouselGroup newRoot = new CarouselGroupEagerSelect();
2017-12-14 19:40:58 +08:00
Task.Run(() =>
{
2017-12-14 19:40:58 +08:00
value.Select(createCarouselSet).Where(g => g != null).ForEach(newRoot.AddChild);
2017-12-16 15:27:39 +08:00
newRoot.Filter(activeCriteria);
}).ContinueWith(t =>
{
Schedule(() =>
{
2017-12-14 19:40:58 +08:00
root = newRoot;
updateItems();
BeatmapSetsChanged?.Invoke();
});
});
}
}
/// <summary>
/// Call after altering <see cref="BeatmapSets"/> in any way.
/// </summary>
private void updateItems()
{
scrollableContent.Clear(false);
2017-12-14 19:40:58 +08:00
Items = root.Drawables.ToList();
yPositionsCache.Invalidate();
if (root.Children == null || root.Children.All(c => c.Filtered))
SelectionChanged?.Invoke(null);
}
private readonly List<float> yPositions = new List<float>();
2017-12-13 11:46:02 +08:00
private Cached yPositionsCache = new Cached();
2017-12-12 16:48:38 +08:00
private readonly Container<DrawableCarouselItem> scrollableContent;
public Bindable<RandomSelectAlgorithm> RandomAlgorithm = new Bindable<RandomSelectAlgorithm>();
private readonly List<CarouselBeatmapSet> previouslyVisitedRandomSets = new List<CarouselBeatmapSet>();
private readonly Stack<CarouselBeatmap> randomSelectedBeatmaps = new Stack<CarouselBeatmap>();
2017-12-13 20:08:12 +08:00
protected List<DrawableCarouselItem> Items = new List<DrawableCarouselItem>();
private CarouselGroup root = new CarouselGroupEagerSelect();
2016-11-21 14:59:46 +08:00
2017-03-17 18:12:54 +08:00
public BeatmapCarousel()
2017-01-18 08:18:15 +08:00
{
2017-12-14 12:49:43 +08:00
Child = new OsuContextMenuContainer
2017-01-18 08:18:15 +08:00
{
RelativeSizeAxes = Axes.X,
2017-09-14 14:41:32 +08:00
AutoSizeAxes = Axes.Y,
2017-12-12 16:48:38 +08:00
Child = scrollableContent = new Container<DrawableCarouselItem>
2017-09-14 14:41:32 +08:00
{
RelativeSizeAxes = Axes.X,
}
2017-12-14 12:49:43 +08:00
};
2017-01-18 08:18:15 +08:00
}
2017-12-13 18:56:16 +08:00
public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet)
{
2017-12-14 19:40:58 +08:00
var existingSet = beatmapSets.FirstOrDefault(b => b.BeatmapSet.ID == beatmapSet.ID);
if (existingSet == null)
return;
2017-12-14 19:40:58 +08:00
root.RemoveChild(existingSet);
updateItems();
}
public void UpdateBeatmapSet(BeatmapSetInfo beatmapSet)
{
2017-12-14 19:40:58 +08:00
CarouselBeatmapSet existingSet = beatmapSets.FirstOrDefault(b => b.BeatmapSet.ID == beatmapSet.ID);
2017-12-12 16:48:38 +08:00
bool hadSelection = existingSet?.State?.Value == CarouselItemState.Selected;
var newSet = createCarouselSet(beatmapSet);
2017-12-14 19:40:58 +08:00
if (existingSet != null)
root.RemoveChild(existingSet);
2017-12-14 19:40:58 +08:00
if (newSet == null)
{
2017-12-14 19:40:58 +08:00
updateItems();
SelectNext();
return;
}
2017-12-14 19:40:58 +08:00
root.AddChild(newSet);
applyActiveCriteria(false, false);
//check if we can/need to maintain our current selection.
2017-12-14 19:40:58 +08:00
if (hadSelection)
select((CarouselItem)newSet.Beatmaps.FirstOrDefault(b => b.Beatmap.ID == selectedBeatmap?.Beatmap.ID) ?? newSet);
updateItems();
}
2017-12-16 15:27:39 +08:00
public void SelectBeatmap(BeatmapInfo beatmap)
{
if (beatmap?.Hidden != false)
return;
2017-12-14 12:49:43 +08:00
foreach (CarouselBeatmapSet group in beatmapSets)
{
2017-12-12 16:48:38 +08:00
var item = group.Beatmaps.FirstOrDefault(p => p.Beatmap.Equals(beatmap));
if (item != null)
{
2017-12-13 11:46:02 +08:00
select(item);
return;
}
}
}
/// <summary>
/// Increment selection in the carousel in a chosen direction.
/// </summary>
/// <param name="direction">The direction to increment. Negative is backwards.</param>
/// <param name="skipDifficulties">Whether to skip individual difficulties and only increment over full groups.</param>
public void SelectNext(int direction = 1, bool skipDifficulties = true)
{
2017-12-14 19:40:58 +08:00
int originalIndex = Items.IndexOf(selectedBeatmap?.Drawables.First());
int currentIndex = originalIndex;
// local function to increment the index in the required direction, wrapping over extremities.
2017-12-13 20:08:12 +08:00
int incrementIndex() => currentIndex = (currentIndex + direction + Items.Count) % Items.Count;
2017-12-12 16:48:38 +08:00
while (incrementIndex() != originalIndex)
{
2017-12-13 20:08:12 +08:00
var item = Items[currentIndex].Item;
2017-05-22 07:53:36 +08:00
2017-12-12 16:48:38 +08:00
if (item.Filtered || item.State == CarouselItemState.Selected) continue;
2017-12-12 16:48:38 +08:00
switch (item)
{
2017-12-12 16:48:38 +08:00
case CarouselBeatmap beatmap:
if (skipDifficulties) continue;
select(beatmap);
return;
case CarouselBeatmapSet set:
if (!skipDifficulties) continue;
2017-12-12 16:48:38 +08:00
select(set);
return;
}
2017-12-12 16:48:38 +08:00
}
}
2017-06-05 17:24:28 +08:00
public void SelectNextRandom()
{
2017-12-14 19:40:58 +08:00
if (!beatmapSets.Any())
return;
2017-12-16 15:27:39 +08:00
var visible = beatmapSets.Where(select => !select.Filtered).ToList();
if (!visible.Any())
return;
2017-12-12 16:48:38 +08:00
if (selectedBeatmap != null)
{
2017-12-12 16:48:38 +08:00
randomSelectedBeatmaps.Push(selectedBeatmap);
// when performing a random, we want to add the current set to the previously visited list
// else the user may be "randomised" to the existing selection.
if (previouslyVisitedRandomSets.LastOrDefault() != selectedBeatmapSet)
previouslyVisitedRandomSets.Add(selectedBeatmapSet);
}
CarouselBeatmapSet set;
2017-06-02 01:54:42 +08:00
if (RandomAlgorithm == RandomSelectAlgorithm.RandomPermutation)
{
var notYetVisitedSets = visible.Except(previouslyVisitedRandomSets).ToList();
if (!notYetVisitedSets.Any())
2017-06-01 02:31:05 +08:00
{
previouslyVisitedRandomSets.Clear();
notYetVisitedSets = visible;
}
set = notYetVisitedSets.ElementAt(RNG.Next(notYetVisitedSets.Count));
previouslyVisitedRandomSets.Add(set);
}
else
set = visible.ElementAt(RNG.Next(visible.Count));
2017-12-14 19:40:58 +08:00
select(set.Beatmaps.Skip(RNG.Next(set.Beatmaps.Count())).FirstOrDefault());
}
2017-06-05 17:24:28 +08:00
public void SelectPreviousRandom()
2017-06-02 01:54:42 +08:00
{
2017-06-05 17:24:28 +08:00
while (randomSelectedBeatmaps.Any())
2017-06-02 01:54:42 +08:00
{
2017-12-12 16:48:38 +08:00
var beatmap = randomSelectedBeatmaps.Pop();
if (!beatmap.Filtered)
2017-06-02 01:54:42 +08:00
{
if (RandomAlgorithm == RandomSelectAlgorithm.RandomPermutation)
previouslyVisitedRandomSets.Remove(selectedBeatmapSet);
2017-12-12 16:48:38 +08:00
select(beatmap);
2017-06-02 01:54:42 +08:00
break;
}
}
}
2017-12-16 15:27:39 +08:00
private FilterCriteria activeCriteria = new FilterCriteria();
2017-12-13 20:08:12 +08:00
protected ScheduledDelegate FilterTask;
public bool AllowSelection = true;
2017-12-16 15:27:39 +08:00
public void FlushPendingFilterOperations()
2017-07-21 16:20:52 +08:00
{
2017-12-13 20:08:12 +08:00
if (FilterTask?.Completed == false)
applyActiveCriteria(false, false);
2017-07-21 16:20:52 +08:00
}
2017-12-16 15:27:39 +08:00
public void Filter(FilterCriteria newCriteria, bool debounce = true)
{
if (newCriteria != null)
2017-12-16 15:27:39 +08:00
activeCriteria = newCriteria;
applyActiveCriteria(debounce, true);
2017-12-16 15:27:39 +08:00
}
private void applyActiveCriteria(bool debounce, bool scroll)
2017-12-16 15:27:39 +08:00
{
Action perform = delegate
2016-11-25 17:14:56 +08:00
{
2017-12-13 20:08:12 +08:00
FilterTask = null;
2017-12-16 15:27:39 +08:00
root.Filter(activeCriteria);
2017-12-14 19:40:58 +08:00
updateItems();
if (scroll) ScrollToSelected(false);
};
2017-12-13 20:08:12 +08:00
FilterTask?.Cancel();
FilterTask = null;
if (debounce)
2017-12-13 20:08:12 +08:00
FilterTask = Scheduler.AddDelayed(perform, 250);
else
perform();
}
2017-07-29 21:03:17 +08:00
public void ScrollToSelected(bool animated = true)
{
float selectedY = computeYPositions(animated);
ScrollTo(selectedY, animated);
}
private CarouselBeatmapSet createCarouselSet(BeatmapSetInfo beatmapSet)
{
if (beatmapSet.Beatmaps.All(b => b.Hidden))
return null;
2017-12-12 16:48:38 +08:00
// todo: remove the need for this.
2017-06-07 19:53:37 +08:00
foreach (var b in beatmapSet.Beatmaps)
2017-04-18 09:04:32 +08:00
{
if (b.Metadata == null)
b.Metadata = beatmapSet.Metadata;
2017-05-06 15:37:53 +08:00
}
2017-12-12 16:48:38 +08:00
var set = new CarouselBeatmapSet(beatmapSet);
foreach (var c in set.Beatmaps)
{
2017-12-12 16:48:38 +08:00
c.State.ValueChanged += v =>
{
if (v == CarouselItemState.Selected)
{
SelectionChanged?.Invoke(c.Beatmap);
yPositionsCache.Invalidate();
Schedule(() => ScrollToSelected());
}
};
}
return set;
}
[BackgroundDependencyLoader(permitNulls: true)]
2017-12-12 16:48:38 +08:00
private void load(OsuConfigManager config)
{
config.BindWith(OsuSetting.RandomSelectAlgorithm, RandomAlgorithm);
}
/// <summary>
2017-12-12 16:48:38 +08:00
/// Computes the target Y positions for every item in the carousel.
/// </summary>
2017-12-12 16:48:38 +08:00
/// <returns>The Y position of the currently selected item.</returns>
private float computeYPositions(bool animated = true)
{
yPositions.Clear();
2016-11-21 14:59:46 +08:00
float currentY = DrawHeight / 2;
float selectedY = currentY;
2017-12-12 16:48:38 +08:00
float lastSetY = 0;
var selected = selectedBeatmap;
2017-12-13 20:08:12 +08:00
foreach (DrawableCarouselItem d in Items)
2017-12-12 16:48:38 +08:00
{
switch (d)
{
2017-12-12 16:48:38 +08:00
case DrawableCarouselBeatmapSet set:
set.MoveToX(set.Item.State == CarouselItemState.Selected ? -100 : 0, 500, Easing.OutExpo);
lastSetY = set.Position.Y;
break;
case DrawableCarouselBeatmap beatmap:
beatmap.MoveToX(beatmap.Item.State == CarouselItemState.Selected ? -50 : 0, 500, Easing.OutExpo);
if (beatmap.Item == selected)
2017-12-12 16:48:38 +08:00
selectedY = currentY + beatmap.DrawHeight / 2 - DrawHeight / 2;
2017-12-12 16:48:38 +08:00
// on first display we want to begin hidden under our group's header.
if (animated && !beatmap.IsPresent)
beatmap.MoveToY(lastSetY);
break;
}
2017-12-13 11:46:02 +08:00
yPositions.Add(currentY);
d.MoveToY(currentY, animated ? 750 : 0, Easing.OutExpo);
if (d.Item.Visible)
currentY += d.DrawHeight + 5;
}
2016-11-21 14:59:46 +08:00
currentY += DrawHeight / 2;
scrollableContent.Height = currentY;
2017-12-12 16:48:38 +08:00
yPositionsCache.Validate();
return selectedY;
2016-11-21 14:59:46 +08:00
}
2017-12-13 11:46:02 +08:00
private void select(CarouselItem item)
{
2017-12-13 11:46:02 +08:00
if (item == null) return;
item.State.Value = CarouselItemState.Selected;
}
protected override bool OnKeyDown(InputState state, KeyDownEventArgs args)
2017-03-02 21:01:53 +08:00
{
int direction = 0;
bool skipDifficulties = false;
switch (args.Key)
2017-03-02 21:01:53 +08:00
{
case Key.Up:
direction = -1;
2017-02-18 00:41:53 +08:00
break;
case Key.Down:
direction = 1;
2017-02-18 00:41:53 +08:00
break;
case Key.Left:
direction = -1;
skipDifficulties = true;
2017-02-18 00:41:53 +08:00
break;
case Key.Right:
direction = 1;
skipDifficulties = true;
2017-03-16 08:18:20 +08:00
break;
2017-02-18 00:41:53 +08:00
}
2017-02-18 06:32:14 +08:00
if (direction == 0)
return base.OnKeyDown(state, args);
SelectNext(direction, skipDifficulties);
return true;
}
protected override void Update()
{
base.Update();
float drawHeight = DrawHeight;
2016-11-25 17:14:56 +08:00
2017-12-12 16:48:38 +08:00
if (!yPositionsCache.IsValid)
computeYPositions();
// Remove all items that should no longer be on-screen
scrollableContent.RemoveAll(delegate (DrawableCarouselItem p)
2017-01-18 08:18:15 +08:00
{
2017-12-12 16:48:38 +08:00
float itemPosY = p.Position.Y;
bool remove = itemPosY < Current - p.DrawHeight || itemPosY > Current + drawHeight || !p.IsPresent;
return remove;
});
2017-12-12 16:48:38 +08:00
// Find index range of all items that should be on-screen
2017-12-13 20:08:12 +08:00
Trace.Assert(Items.Count == yPositions.Count);
2017-12-12 16:48:38 +08:00
int firstIndex = yPositions.BinarySearch(Current - DrawableCarouselItem.MAX_HEIGHT);
if (firstIndex < 0) firstIndex = ~firstIndex;
int lastIndex = yPositions.BinarySearch(Current + drawHeight);
if (lastIndex < 0)
{
lastIndex = ~lastIndex;
2017-12-12 16:48:38 +08:00
// Add the first item of the last visible beatmap group to preload its data.
2017-12-13 20:08:12 +08:00
if (lastIndex != 0 && Items[lastIndex - 1] is DrawableCarouselBeatmapSet)
lastIndex++;
}
2017-12-12 16:48:38 +08:00
// Add those items within the previously found index range that should be displayed.
2016-11-25 17:14:56 +08:00
for (int i = firstIndex; i < lastIndex; ++i)
{
2017-12-13 20:08:12 +08:00
DrawableCarouselItem item = Items[i];
2017-03-04 16:34:39 +08:00
// Only add if we're not already part of the content.
2017-12-12 16:48:38 +08:00
if (!scrollableContent.Contains(item))
{
if (!item.Item.Visible) continue;
2017-12-12 16:48:38 +08:00
// Makes sure headers are always _below_ items,
2017-03-04 16:34:39 +08:00
// and depth flows downward.
2017-12-13 20:08:12 +08:00
item.Depth = i + (item is DrawableCarouselBeatmapSet ? Items.Count : 0);
2017-12-12 16:48:38 +08:00
switch (item.LoadState)
{
case LoadState.NotLoaded:
2017-12-12 16:48:38 +08:00
LoadComponentAsync(item);
break;
case LoadState.Loading:
break;
default:
2017-12-12 16:48:38 +08:00
scrollableContent.Add(item);
break;
}
}
}
2017-12-12 16:48:38 +08:00
// Update externally controlled state of currently visible items
2017-03-04 16:34:39 +08:00
// (e.g. x-offset and opacity).
float halfHeight = drawHeight / 2;
2017-12-12 16:48:38 +08:00
foreach (DrawableCarouselItem p in scrollableContent.Children)
updateItem(p, halfHeight);
}
/// <summary>
2017-12-12 16:48:38 +08:00
/// Computes the x-offset of currently visible items. Makes the carousel appear round.
/// </summary>
/// <param name="dist">
/// Vertical distance from the center of the carousel container
/// ranging from -1 to 1.
/// </param>
/// <param name="halfHeight">Half the height of the carousel container.</param>
private static float offsetX(float dist, float halfHeight)
{
// The radius of the circle the carousel moves on.
const float circle_radius = 3;
double discriminant = Math.Max(0, circle_radius * circle_radius - dist * dist);
float x = (circle_radius - (float)Math.Sqrt(discriminant)) * halfHeight;
return 125 + x;
}
/// <summary>
2017-12-12 16:48:38 +08:00
/// Update a item's x position and multiplicative alpha based on its y position and
/// the current scroll position.
/// </summary>
2017-12-12 16:48:38 +08:00
/// <param name="p">The item to be updated.</param>
/// <param name="halfHeight">Half the draw height of the carousel container.</param>
2017-12-12 16:48:38 +08:00
private void updateItem(DrawableCarouselItem p, float halfHeight)
{
var height = p.IsPresent ? p.DrawHeight : 0;
2017-12-12 16:48:38 +08:00
float itemDrawY = p.Position.Y - Current + height / 2;
float dist = Math.Abs(1f - itemDrawY / halfHeight);
2017-03-04 21:05:02 +08:00
// Setting the origin position serves as an additive position on top of potential
2017-12-12 16:48:38 +08:00
// local transformation we may want to apply (e.g. when a item gets selected, we
// may want to smoothly transform it leftwards.)
p.OriginPosition = new Vector2(-offsetX(dist, halfHeight), 0);
2017-03-04 21:05:02 +08:00
// We are applying a multiplicative alpha (which is internally done by nesting an
// additional container and setting that container's alpha) such that we can
// layer transformations on top, with a similar reasoning to the previous comment.
p.SetMultiplicativeAlpha(MathHelper.Clamp(1.75f - 1.5f * dist, 0, 1));
}
}
}