1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-13 22:35:23 +08:00

Merge pull request #10494 from peppy/beatmap-carousel-refactor

Add beatmap set level pooling to beatmap carousel
This commit is contained in:
Dan Balasescu 2020-10-20 14:25:56 +09:00 committed by GitHub
commit f35611b452
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 875 additions and 528 deletions

View File

@ -11,6 +11,7 @@ using osu.Framework.Allocation;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Rulesets; using osu.Game.Rulesets;
@ -40,6 +41,12 @@ namespace osu.Game.Tests.Visual.SongSelect
this.rulesets = rulesets; this.rulesets = rulesets;
} }
[Test]
public void TestManyPanels()
{
loadBeatmaps(count: 5000, randomDifficulties: true);
}
[Test] [Test]
public void TestKeyRepeat() public void TestKeyRepeat()
{ {
@ -707,21 +714,22 @@ namespace osu.Game.Tests.Visual.SongSelect
checkVisibleItemCount(true, 15); checkVisibleItemCount(true, 15);
} }
private void loadBeatmaps(List<BeatmapSetInfo> beatmapSets = null, Func<FilterCriteria> initialCriteria = null, Action<BeatmapCarousel> carouselAdjust = null) private void loadBeatmaps(List<BeatmapSetInfo> beatmapSets = null, Func<FilterCriteria> initialCriteria = null, Action<BeatmapCarousel> carouselAdjust = null, int? count = null, bool randomDifficulties = false)
{ {
createCarousel(carouselAdjust); bool changed = false;
createCarousel(c =>
{
carouselAdjust?.Invoke(c);
if (beatmapSets == null) if (beatmapSets == null)
{ {
beatmapSets = new List<BeatmapSetInfo>(); beatmapSets = new List<BeatmapSetInfo>();
for (int i = 1; i <= set_count; i++) for (int i = 1; i <= (count ?? set_count); i++)
beatmapSets.Add(createTestBeatmapSet(i)); beatmapSets.Add(createTestBeatmapSet(i, randomDifficulties));
} }
bool changed = false;
AddStep($"Load {(beatmapSets.Count > 0 ? beatmapSets.Count.ToString() : "some")} beatmaps", () =>
{
carousel.Filter(initialCriteria?.Invoke() ?? new FilterCriteria()); carousel.Filter(initialCriteria?.Invoke() ?? new FilterCriteria());
carousel.BeatmapSetsChanged = () => changed = true; carousel.BeatmapSetsChanged = () => changed = true;
carousel.BeatmapSets = beatmapSets; carousel.BeatmapSets = beatmapSets;
@ -807,7 +815,7 @@ namespace osu.Game.Tests.Visual.SongSelect
private bool selectedBeatmapVisible() private bool selectedBeatmapVisible()
{ {
var currentlySelected = carousel.Items.Find(s => s.Item is CarouselBeatmap && s.Item.State.Value == CarouselItemState.Selected); var currentlySelected = carousel.Items.FirstOrDefault(s => s.Item is CarouselBeatmap && s.Item.State.Value == CarouselItemState.Selected);
if (currentlySelected == null) if (currentlySelected == null)
return true; return true;
@ -820,7 +828,7 @@ namespace osu.Game.Tests.Visual.SongSelect
AddAssert("Selection is visible", selectedBeatmapVisible); AddAssert("Selection is visible", selectedBeatmapVisible);
} }
private BeatmapSetInfo createTestBeatmapSet(int id) private BeatmapSetInfo createTestBeatmapSet(int id, bool randomDifficultyCount = false)
{ {
return new BeatmapSetInfo return new BeatmapSetInfo
{ {
@ -834,42 +842,37 @@ namespace osu.Game.Tests.Visual.SongSelect
Title = $"test set #{id}!", Title = $"test set #{id}!",
AuthorString = string.Concat(Enumerable.Repeat((char)('z' - Math.Min(25, id - 1)), 5)) AuthorString = string.Concat(Enumerable.Repeat((char)('z' - Math.Min(25, id - 1)), 5))
}, },
Beatmaps = new List<BeatmapInfo>(new[] Beatmaps = getBeatmaps(randomDifficultyCount ? RNG.Next(1, 20) : 3).ToList()
{
new BeatmapInfo
{
OnlineBeatmapID = id * 10,
Version = "Normal",
StarDifficulty = 2,
BaseDifficulty = new BeatmapDifficulty
{
OverallDifficulty = 3.5f,
}
},
new BeatmapInfo
{
OnlineBeatmapID = id * 10 + 1,
Version = "Hard",
StarDifficulty = 5,
BaseDifficulty = new BeatmapDifficulty
{
OverallDifficulty = 5,
}
},
new BeatmapInfo
{
OnlineBeatmapID = id * 10 + 2,
Version = "Insane",
StarDifficulty = 6,
BaseDifficulty = new BeatmapDifficulty
{
OverallDifficulty = 7,
}
},
}),
}; };
} }
private IEnumerable<BeatmapInfo> getBeatmaps(int count)
{
int id = 0;
for (int i = 0; i < count; i++)
{
float diff = (float)i / count * 10;
string version = "Normal";
if (diff > 6.6)
version = "Insane";
else if (diff > 3.3)
version = "Hard";
yield return new BeatmapInfo
{
OnlineBeatmapID = id++ * 10,
Version = version,
StarDifficulty = diff,
BaseDifficulty = new BeatmapDifficulty
{
OverallDifficulty = diff,
}
};
}
}
private BeatmapSetInfo createTestBeatmapSetWithManyDifficulties(int id) private BeatmapSetInfo createTestBeatmapSetWithManyDifficulties(int id)
{ {
var toReturn = new BeatmapSetInfo var toReturn = new BeatmapSetInfo
@ -908,10 +911,25 @@ namespace osu.Game.Tests.Visual.SongSelect
private class TestBeatmapCarousel : BeatmapCarousel private class TestBeatmapCarousel : BeatmapCarousel
{ {
public new List<DrawableCarouselItem> Items => base.Items;
public bool PendingFilterTask => PendingFilter != null; public bool PendingFilterTask => PendingFilter != null;
public IEnumerable<DrawableCarouselItem> Items
{
get
{
foreach (var item in ScrollableContent)
{
yield return item;
if (item is DrawableCarouselBeatmapSet set)
{
foreach (var difficulty in set.DrawableBeatmaps)
yield return difficulty;
}
}
}
}
protected override IEnumerable<BeatmapSetInfo> GetLoadableBeatmaps() => Enumerable.Empty<BeatmapSetInfo>(); protected override IEnumerable<BeatmapSetInfo> GetLoadableBeatmaps() => Enumerable.Empty<BeatmapSetInfo>();
} }
} }

View File

@ -507,7 +507,7 @@ namespace osu.Game.Tests.Visual.SongSelect
var selectedPanel = songSelect.Carousel.ChildrenOfType<DrawableCarouselBeatmapSet>().First(s => s.Item.State.Value == CarouselItemState.Selected); var selectedPanel = songSelect.Carousel.ChildrenOfType<DrawableCarouselBeatmapSet>().First(s => s.Item.State.Value == CarouselItemState.Selected);
// special case for converts checked here. // special case for converts checked here.
return selectedPanel.ChildrenOfType<DrawableCarouselBeatmapSet.FilterableDifficultyIcon>().All(i => return selectedPanel.ChildrenOfType<FilterableDifficultyIcon>().All(i =>
i.IsFiltered || i.Item.Beatmap.Ruleset.ID == targetRuleset || i.Item.Beatmap.Ruleset.ID == 0); i.IsFiltered || i.Item.Beatmap.Ruleset.ID == targetRuleset || i.Item.Beatmap.Ruleset.ID == 0);
}); });
@ -606,10 +606,10 @@ namespace osu.Game.Tests.Visual.SongSelect
set = songSelect.Carousel.ChildrenOfType<DrawableCarouselBeatmapSet>().First(); set = songSelect.Carousel.ChildrenOfType<DrawableCarouselBeatmapSet>().First();
}); });
DrawableCarouselBeatmapSet.FilterableDifficultyIcon difficultyIcon = null; FilterableDifficultyIcon difficultyIcon = null;
AddStep("Find an icon", () => AddStep("Find an icon", () =>
{ {
difficultyIcon = set.ChildrenOfType<DrawableCarouselBeatmapSet.FilterableDifficultyIcon>() difficultyIcon = set.ChildrenOfType<FilterableDifficultyIcon>()
.First(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex()); .First(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex());
}); });
@ -634,13 +634,13 @@ namespace osu.Game.Tests.Visual.SongSelect
})); }));
BeatmapInfo filteredBeatmap = null; BeatmapInfo filteredBeatmap = null;
DrawableCarouselBeatmapSet.FilterableDifficultyIcon filteredIcon = null; FilterableDifficultyIcon filteredIcon = null;
AddStep("Get filtered icon", () => AddStep("Get filtered icon", () =>
{ {
filteredBeatmap = songSelect.Carousel.SelectedBeatmapSet.Beatmaps.First(b => b.BPM < maxBPM); filteredBeatmap = songSelect.Carousel.SelectedBeatmapSet.Beatmaps.First(b => b.BPM < maxBPM);
int filteredBeatmapIndex = getBeatmapIndex(filteredBeatmap.BeatmapSet, filteredBeatmap); int filteredBeatmapIndex = getBeatmapIndex(filteredBeatmap.BeatmapSet, filteredBeatmap);
filteredIcon = set.ChildrenOfType<DrawableCarouselBeatmapSet.FilterableDifficultyIcon>().ElementAt(filteredBeatmapIndex); filteredIcon = set.ChildrenOfType<FilterableDifficultyIcon>().ElementAt(filteredBeatmapIndex);
}); });
AddStep("Click on a filtered difficulty", () => AddStep("Click on a filtered difficulty", () =>
@ -674,10 +674,10 @@ namespace osu.Game.Tests.Visual.SongSelect
return set != null; return set != null;
}); });
DrawableCarouselBeatmapSet.FilterableDifficultyIcon difficultyIcon = null; FilterableDifficultyIcon difficultyIcon = null;
AddStep("Find an icon for different ruleset", () => AddStep("Find an icon for different ruleset", () =>
{ {
difficultyIcon = set.ChildrenOfType<DrawableCarouselBeatmapSet.FilterableDifficultyIcon>() difficultyIcon = set.ChildrenOfType<FilterableDifficultyIcon>()
.First(icon => icon.Item.Beatmap.Ruleset.ID == 3); .First(icon => icon.Item.Beatmap.Ruleset.ID == 3);
}); });
@ -725,10 +725,10 @@ namespace osu.Game.Tests.Visual.SongSelect
return set != null; return set != null;
}); });
DrawableCarouselBeatmapSet.FilterableGroupedDifficultyIcon groupIcon = null; FilterableGroupedDifficultyIcon groupIcon = null;
AddStep("Find group icon for different ruleset", () => AddStep("Find group icon for different ruleset", () =>
{ {
groupIcon = set.ChildrenOfType<DrawableCarouselBeatmapSet.FilterableGroupedDifficultyIcon>() groupIcon = set.ChildrenOfType<FilterableGroupedDifficultyIcon>()
.First(icon => icon.Items.First().Beatmap.Ruleset.ID == 3); .First(icon => icon.Items.First().Beatmap.Ruleset.ID == 3);
}); });
@ -821,9 +821,9 @@ namespace osu.Game.Tests.Visual.SongSelect
private int getCurrentBeatmapIndex() => getBeatmapIndex(songSelect.Carousel.SelectedBeatmapSet, songSelect.Carousel.SelectedBeatmap); private int getCurrentBeatmapIndex() => getBeatmapIndex(songSelect.Carousel.SelectedBeatmapSet, songSelect.Carousel.SelectedBeatmap);
private int getDifficultyIconIndex(DrawableCarouselBeatmapSet set, DrawableCarouselBeatmapSet.FilterableDifficultyIcon icon) private int getDifficultyIconIndex(DrawableCarouselBeatmapSet set, FilterableDifficultyIcon icon)
{ {
return set.ChildrenOfType<DrawableCarouselBeatmapSet.FilterableDifficultyIcon>().ToList().FindIndex(i => i == icon); return set.ChildrenOfType<FilterableDifficultyIcon>().ToList().FindIndex(i => i == icon);
} }
private void addRulesetImportStep(int id) => AddStep($"import test map for ruleset {id}", () => importForRuleset(id)); private void addRulesetImportStep(int id) => AddStep($"import test map for ruleset {id}", () => importForRuleset(id));

View File

@ -1,29 +1,29 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using osuTK;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using osu.Game.Configuration;
using osuTK.Input;
using osu.Framework.Utils;
using System.Diagnostics; using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Caching; using osu.Framework.Caching;
using osu.Framework.Threading; using osu.Framework.Graphics;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Input.Bindings; using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Cursor;
using osu.Game.Input.Bindings; using osu.Game.Input.Bindings;
using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Carousel;
using osuTK;
using osuTK.Input;
namespace osu.Game.Screens.Select namespace osu.Game.Screens.Select
{ {
@ -74,6 +74,18 @@ namespace osu.Game.Screens.Select
public override bool PropagatePositionalInputSubTree => AllowSelection; public override bool PropagatePositionalInputSubTree => AllowSelection;
public override bool PropagateNonPositionalInputSubTree => AllowSelection; public override bool PropagateNonPositionalInputSubTree => AllowSelection;
private (int first, int last) displayedRange;
/// <summary>
/// Extend the range to retain already loaded pooled drawables.
/// </summary>
private const float distance_offscreen_before_unload = 1024;
/// <summary>
/// Extend the range to update positions / retrieve pooled drawables outside of visible range.
/// </summary>
private const float distance_offscreen_to_preload = 512; // todo: adjust this appropriately once we can make set panel contents load while off-screen.
/// <summary> /// <summary>
/// Whether carousel items have completed asynchronously loaded. /// Whether carousel items have completed asynchronously loaded.
/// </summary> /// </summary>
@ -94,16 +106,13 @@ namespace osu.Game.Screens.Select
{ {
CarouselRoot newRoot = new CarouselRoot(this); CarouselRoot newRoot = new CarouselRoot(this);
beatmapSets.Select(createCarouselSet).Where(g => g != null).ForEach(newRoot.AddChild); newRoot.AddChildren(beatmapSets.Select(createCarouselSet).Where(g => g != null));
// preload drawables as the ctor overhead is quite high currently.
_ = newRoot.Drawables;
root = newRoot; root = newRoot;
if (selectedBeatmapSet != null && !beatmapSets.Contains(selectedBeatmapSet.BeatmapSet)) if (selectedBeatmapSet != null && !beatmapSets.Contains(selectedBeatmapSet.BeatmapSet))
selectedBeatmapSet = null; selectedBeatmapSet = null;
scrollableContent.Clear(false); ScrollableContent.Clear(false);
itemsCache.Invalidate(); itemsCache.Invalidate();
scrollPositionCache.Invalidate(); scrollPositionCache.Invalidate();
@ -118,11 +127,12 @@ namespace osu.Game.Screens.Select
}); });
} }
private readonly List<float> yPositions = new List<float>(); private readonly List<CarouselItem> visibleItems = new List<CarouselItem>();
private readonly Cached itemsCache = new Cached(); private readonly Cached itemsCache = new Cached();
private readonly Cached scrollPositionCache = new Cached(); private readonly Cached scrollPositionCache = new Cached();
private readonly Container<DrawableCarouselItem> scrollableContent; protected readonly Container<DrawableCarouselItem> ScrollableContent;
public Bindable<bool> RightClickScrollingEnabled = new Bindable<bool>(); public Bindable<bool> RightClickScrollingEnabled = new Bindable<bool>();
@ -130,8 +140,6 @@ namespace osu.Game.Screens.Select
private readonly List<CarouselBeatmapSet> previouslyVisitedRandomSets = new List<CarouselBeatmapSet>(); private readonly List<CarouselBeatmapSet> previouslyVisitedRandomSets = new List<CarouselBeatmapSet>();
private readonly Stack<CarouselBeatmap> randomSelectedBeatmaps = new Stack<CarouselBeatmap>(); private readonly Stack<CarouselBeatmap> randomSelectedBeatmaps = new Stack<CarouselBeatmap>();
protected List<DrawableCarouselItem> Items = new List<DrawableCarouselItem>();
private CarouselRoot root; private CarouselRoot root;
private IBindable<WeakReference<BeatmapSetInfo>> itemUpdated; private IBindable<WeakReference<BeatmapSetInfo>> itemUpdated;
@ -139,6 +147,8 @@ namespace osu.Game.Screens.Select
private IBindable<WeakReference<BeatmapInfo>> itemHidden; private IBindable<WeakReference<BeatmapInfo>> itemHidden;
private IBindable<WeakReference<BeatmapInfo>> itemRestored; private IBindable<WeakReference<BeatmapInfo>> itemRestored;
private readonly DrawablePool<DrawableCarouselBeatmapSet> setPool = new DrawablePool<DrawableCarouselBeatmapSet>(100);
public BeatmapCarousel() public BeatmapCarousel()
{ {
root = new CarouselRoot(this); root = new CarouselRoot(this);
@ -149,11 +159,15 @@ namespace osu.Game.Screens.Select
{ {
Masking = false, Masking = false,
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Child = scrollableContent = new Container<DrawableCarouselItem> Children = new Drawable[]
{
setPool,
ScrollableContent = new Container<DrawableCarouselItem>
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
} }
} }
}
}; };
} }
@ -178,6 +192,7 @@ namespace osu.Game.Screens.Select
itemRestored = beatmaps.BeatmapRestored.GetBoundCopy(); itemRestored = beatmaps.BeatmapRestored.GetBoundCopy();
itemRestored.BindValueChanged(beatmapRestored); itemRestored.BindValueChanged(beatmapRestored);
if (!beatmapSets.Any())
loadBeatmapSets(GetLoadableBeatmaps()); loadBeatmapSets(GetLoadableBeatmaps());
} }
@ -558,71 +573,101 @@ namespace osu.Game.Screens.Select
{ {
base.Update(); base.Update();
if (!itemsCache.IsValid) bool revalidateItems = !itemsCache.IsValid;
updateItems();
// Remove all items that should no longer be on-screen // First we iterate over all non-filtered carousel items and populate their
scrollableContent.RemoveAll(p => p.Y < visibleUpperBound - p.DrawHeight || p.Y > visibleBottomBound || !p.IsPresent); // vertical position data.
if (revalidateItems)
updateYPositions();
// Find index range of all items that should be on-screen // This data is consumed to find the currently displayable range.
Trace.Assert(Items.Count == yPositions.Count); // This is the range we want to keep drawables for, and should exceed the visible range slightly to avoid drawable churn.
var newDisplayRange = getDisplayRange();
int firstIndex = yPositions.BinarySearch(visibleUpperBound - DrawableCarouselItem.MAX_HEIGHT); // If the filtered items or visible range has changed, pooling requirements need to be checked.
if (firstIndex < 0) firstIndex = ~firstIndex; // This involves fetching new items from the pool, returning no-longer required items.
int lastIndex = yPositions.BinarySearch(visibleBottomBound); if (revalidateItems || newDisplayRange != displayedRange)
if (lastIndex < 0) lastIndex = ~lastIndex;
int notVisibleCount = 0;
// Add those items within the previously found index range that should be displayed.
for (int i = firstIndex; i < lastIndex; ++i)
{ {
DrawableCarouselItem item = Items[i]; displayedRange = newDisplayRange;
if (!item.Item.Visible) if (visibleItems.Count > 0)
{ {
if (!item.IsPresent) var toDisplay = visibleItems.GetRange(displayedRange.first, displayedRange.last - displayedRange.first + 1);
notVisibleCount++;
foreach (var panel in ScrollableContent.Children)
{
if (toDisplay.Remove(panel.Item))
{
// panel already displayed.
continue; continue;
} }
float depth = i + (item is DrawableCarouselBeatmapSet ? -Items.Count : 0); // panel loaded as drawable but not required by visible range.
// remove but only if too far off-screen
// Only add if we're not already part of the content. if (panel.Y + panel.DrawHeight < visibleUpperBound - distance_offscreen_before_unload || panel.Y > visibleBottomBound + distance_offscreen_before_unload)
if (!scrollableContent.Contains(item))
{ {
// Makes sure headers are always _below_ items, // may want a fade effect here (could be seen if a huge change happens, like a set with 20 difficulties becomes selected).
// and depth flows downward. panel.ClearTransforms();
item.Depth = depth; panel.Expire();
}
}
switch (item.LoadState) // Add those items within the previously found index range that should be displayed.
foreach (var item in toDisplay)
{ {
case LoadState.NotLoaded: var panel = setPool.Get(p => p.Item = item);
LoadComponentAsync(item);
break;
case LoadState.Loading: panel.Depth = item.CarouselYPosition;
break; panel.Y = item.CarouselYPosition;
default: ScrollableContent.Add(panel);
scrollableContent.Add(item);
break;
} }
} }
else }
// Finally, if the filtered items have changed, animate drawables to their new locations.
// This is common if a selected/collapsed state has changed.
if (revalidateItems)
{ {
scrollableContent.ChangeChildDepth(item, depth); foreach (DrawableCarouselItem panel in ScrollableContent.Children)
{
panel.MoveToY(panel.Item.CarouselYPosition, 800, Easing.OutQuint);
} }
} }
// this is not actually useful right now, but once we have groups may well be. // Update externally controlled state of currently visible items (e.g. x-offset and opacity).
if (notVisibleCount > 50) // This is a per-frame update on all drawable panels.
itemsCache.Invalidate(); foreach (DrawableCarouselItem item in ScrollableContent.Children)
{
updateItem(item);
// Update externally controlled state of currently visible items if (item is DrawableCarouselBeatmapSet set)
// (e.g. x-offset and opacity). {
foreach (DrawableCarouselItem p in scrollableContent.Children) foreach (var diff in set.DrawableBeatmaps)
updateItem(p); updateItem(diff, item);
}
}
}
private readonly CarouselBoundsItem carouselBoundsItem = new CarouselBoundsItem();
private (int firstIndex, int lastIndex) getDisplayRange()
{
// Find index range of all items that should be on-screen
carouselBoundsItem.CarouselYPosition = visibleUpperBound - distance_offscreen_to_preload;
int firstIndex = visibleItems.BinarySearch(carouselBoundsItem);
if (firstIndex < 0) firstIndex = ~firstIndex;
carouselBoundsItem.CarouselYPosition = visibleBottomBound + distance_offscreen_to_preload;
int lastIndex = visibleItems.BinarySearch(carouselBoundsItem);
if (lastIndex < 0) lastIndex = ~lastIndex;
// as we can't be 100% sure on the size of individual carousel drawables,
// always play it safe and extend bounds by one.
firstIndex = Math.Max(0, firstIndex - 1);
lastIndex = Math.Clamp(lastIndex + 1, firstIndex, Math.Max(0, visibleItems.Count - 1));
return (firstIndex, lastIndex);
} }
protected override void UpdateAfterChildren() protected override void UpdateAfterChildren()
@ -633,15 +678,6 @@ namespace osu.Game.Screens.Select
updateScrollPosition(); updateScrollPosition();
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
// aggressively dispose "off-screen" items to reduce GC pressure.
foreach (var i in Items)
i.Dispose();
}
private void beatmapRemoved(ValueChangedEvent<WeakReference<BeatmapSetInfo>> weakItem) private void beatmapRemoved(ValueChangedEvent<WeakReference<BeatmapSetInfo>> weakItem)
{ {
if (weakItem.NewValue.TryGetTarget(out var item)) if (weakItem.NewValue.TryGetTarget(out var item))
@ -698,79 +734,62 @@ namespace osu.Game.Screens.Select
return set; return set;
} }
private const float panel_padding = 5;
/// <summary> /// <summary>
/// Computes the target Y positions for every item in the carousel. /// Computes the target Y positions for every item in the carousel.
/// </summary> /// </summary>
/// <returns>The Y position of the currently selected item.</returns> /// <returns>The Y position of the currently selected item.</returns>
private void updateItems() private void updateYPositions()
{ {
Items = root.Drawables.ToList(); visibleItems.Clear();
yPositions.Clear();
float currentY = visibleHalfHeight; float currentY = visibleHalfHeight;
DrawableCarouselBeatmapSet lastSet = null;
scrollTarget = null; scrollTarget = null;
foreach (DrawableCarouselItem d in Items) foreach (CarouselItem item in root.Children)
{ {
if (d.IsPresent) if (item.Filtered.Value)
{ continue;
switch (d)
{
case DrawableCarouselBeatmapSet set:
{
lastSet = set;
set.MoveToX(set.Item.State.Value == CarouselItemState.Selected ? -100 : 0, 500, Easing.OutExpo); switch (item)
set.MoveToY(currentY, 750, Easing.OutExpo); {
break; case CarouselBeatmapSet set:
} {
visibleItems.Add(set);
case DrawableCarouselBeatmap beatmap: set.CarouselYPosition = currentY;
if (item.State.Value == CarouselItemState.Selected)
{ {
if (beatmap.Item.State.Value == CarouselItemState.Selected)
// scroll position at currentY makes the set panel appear at the very top of the carousel's screen space // scroll position at currentY makes the set panel appear at the very top of the carousel's screen space
// move down by half of visible height (height of the carousel's visible extent, including semi-transparent areas) // move down by half of visible height (height of the carousel's visible extent, including semi-transparent areas)
// then reapply the top semi-transparent area (because carousel's screen space starts below it) // then reapply the top semi-transparent area (because carousel's screen space starts below it)
// and finally add half of the panel's own height to achieve vertical centering of the panel itself scrollTarget = currentY + DrawableCarouselBeatmapSet.HEIGHT - visibleHalfHeight + BleedTop;
scrollTarget = currentY - visibleHalfHeight + BleedTop + beatmap.DrawHeight / 2;
void performMove(float y, float? startY = null) foreach (var b in set.Beatmaps)
{ {
if (startY != null) beatmap.MoveTo(new Vector2(0, startY.Value)); if (!b.Visible)
beatmap.MoveToX(beatmap.Item.State.Value == CarouselItemState.Selected ? -50 : 0, 500, Easing.OutExpo); continue;
beatmap.MoveToY(y, 750, Easing.OutExpo);
if (b.State.Value == CarouselItemState.Selected)
{
scrollTarget += b.TotalHeight / 2;
break;
} }
Debug.Assert(lastSet != null); scrollTarget += b.TotalHeight;
}
float? setY = null;
if (!d.IsLoaded || beatmap.Alpha == 0) // can't use IsPresent due to DrawableCarouselItem override.
setY = lastSet.Y + lastSet.DrawHeight + 5;
if (d.IsLoaded)
performMove(currentY, setY);
else
{
float y = currentY;
d.OnLoadComplete += _ => performMove(y, setY);
} }
currentY += set.TotalHeight + panel_padding;
break; break;
} }
} }
} }
yPositions.Add(currentY);
if (d.Item.Visible)
currentY += d.DrawHeight + 5;
}
currentY += visibleHalfHeight; currentY += visibleHalfHeight;
scrollableContent.Height = currentY; ScrollableContent.Height = currentY;
if (BeatmapSetsLoaded && (selectedBeatmapSet == null || selectedBeatmap == null || selectedBeatmapSet.State.Value != CarouselItemState.Selected)) if (BeatmapSetsLoaded && (selectedBeatmapSet == null || selectedBeatmap == null || selectedBeatmapSet.State.Value != CarouselItemState.Selected))
{ {
@ -821,21 +840,31 @@ namespace osu.Game.Screens.Select
/// Update a item's x position and multiplicative alpha based on its y position and /// Update a item's x position and multiplicative alpha based on its y position and
/// the current scroll position. /// the current scroll position.
/// </summary> /// </summary>
/// <param name="p">The item to be updated.</param> /// <param name="item">The item to be updated.</param>
private void updateItem(DrawableCarouselItem p) /// <param name="parent">For nested items, the parent of the item to be updated.</param>
private void updateItem(DrawableCarouselItem item, DrawableCarouselItem parent = null)
{ {
float itemDrawY = p.Position.Y - visibleUpperBound + p.DrawHeight / 2; Vector2 posInScroll = ScrollableContent.ToLocalSpace(item.Header.ScreenSpaceDrawQuad.Centre);
float itemDrawY = posInScroll.Y - visibleUpperBound;
float dist = Math.Abs(1f - itemDrawY / visibleHalfHeight); float dist = Math.Abs(1f - itemDrawY / visibleHalfHeight);
// Setting the origin position serves as an additive position on top of potential // adjusting the item's overall X position can cause it to become masked away when
// local transformation we may want to apply (e.g. when a item gets selected, we // child items (difficulties) are still visible.
// may want to smoothly transform it leftwards.) item.Header.X = offsetX(dist, visibleHalfHeight) - (parent?.X ?? 0);
p.OriginPosition = new Vector2(-offsetX(dist, visibleHalfHeight), 0);
// We are applying a multiplicative alpha (which is internally done by nesting an // 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 // additional container and setting that container's alpha) such that we can
// layer transformations on top, with a similar reasoning to the previous comment. // layer alpha transformations on top.
p.SetMultiplicativeAlpha(Math.Clamp(1.75f - 1.5f * dist, 0, 1)); item.SetMultiplicativeAlpha(Math.Clamp(1.75f - 1.5f * dist, 0, 1));
}
/// <summary>
/// A carousel item strictly used for binary search purposes.
/// </summary>
private class CarouselBoundsItem : CarouselItem
{
public override DrawableCarouselItem CreateDrawableRepresentation() =>
throw new NotImplementedException();
} }
private class CarouselRoot : CarouselGroupEagerSelect private class CarouselRoot : CarouselGroupEagerSelect
@ -869,6 +898,7 @@ namespace osu.Game.Screens.Select
/// </summary> /// </summary>
public bool UserScrolling { get; private set; } public bool UserScrolling { get; private set; }
// ReSharper disable once OptionalParameterHierarchyMismatch 2020.3 EAP4 bug. (https://youtrack.jetbrains.com/issue/RSRP-481535?p=RIDER-51910)
protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default)
{ {
UserScrolling = true; UserScrolling = true;

View File

@ -10,6 +10,8 @@ namespace osu.Game.Screens.Select.Carousel
{ {
public class CarouselBeatmap : CarouselItem public class CarouselBeatmap : CarouselItem
{ {
public override float TotalHeight => DrawableCarouselBeatmap.HEIGHT;
public readonly BeatmapInfo Beatmap; public readonly BeatmapInfo Beatmap;
public CarouselBeatmap(BeatmapInfo beatmap) public CarouselBeatmap(BeatmapInfo beatmap)
@ -18,7 +20,7 @@ namespace osu.Game.Screens.Select.Carousel
State.Value = CarouselItemState.Collapsed; State.Value = CarouselItemState.Collapsed;
} }
protected override DrawableCarouselItem CreateDrawableRepresentation() => new DrawableCarouselBeatmap(this); public override DrawableCarouselItem CreateDrawableRepresentation() => new DrawableCarouselBeatmap(this);
public override void Filter(FilterCriteria criteria) public override void Filter(FilterCriteria criteria)
{ {

View File

@ -12,6 +12,21 @@ namespace osu.Game.Screens.Select.Carousel
{ {
public class CarouselBeatmapSet : CarouselGroupEagerSelect public class CarouselBeatmapSet : CarouselGroupEagerSelect
{ {
public override float TotalHeight
{
get
{
switch (State.Value)
{
case CarouselItemState.Selected:
return DrawableCarouselBeatmapSet.HEIGHT + Children.Count(c => c.Visible) * DrawableCarouselBeatmap.HEIGHT;
default:
return DrawableCarouselBeatmapSet.HEIGHT;
}
}
}
public IEnumerable<CarouselBeatmap> Beatmaps => InternalChildren.OfType<CarouselBeatmap>(); public IEnumerable<CarouselBeatmap> Beatmaps => InternalChildren.OfType<CarouselBeatmap>();
public BeatmapSetInfo BeatmapSet; public BeatmapSetInfo BeatmapSet;
@ -28,8 +43,6 @@ namespace osu.Game.Screens.Select.Carousel
.ForEach(AddChild); .ForEach(AddChild);
} }
protected override DrawableCarouselItem CreateDrawableRepresentation() => new DrawableCarouselBeatmapSet(this);
protected override CarouselItem GetNextToSelect() protected override CarouselItem GetNextToSelect()
{ {
if (LastSelected == null) if (LastSelected == null)

View File

@ -11,7 +11,7 @@ namespace osu.Game.Screens.Select.Carousel
/// </summary> /// </summary>
public class CarouselGroup : CarouselItem public class CarouselGroup : CarouselItem
{ {
protected override DrawableCarouselItem CreateDrawableRepresentation() => null; public override DrawableCarouselItem CreateDrawableRepresentation() => null;
public IReadOnlyList<CarouselItem> Children => InternalChildren; public IReadOnlyList<CarouselItem> Children => InternalChildren;
@ -23,22 +23,6 @@ namespace osu.Game.Screens.Select.Carousel
/// </summary> /// </summary>
private ulong currentChildID; private ulong currentChildID;
public override List<DrawableCarouselItem> Drawables
{
get
{
var drawables = base.Drawables;
// if we are explicitly not present, don't ever present children.
// without this check, children drawables can potentially be presented without their group header.
if (DrawableRepresentation.Value?.IsPresent == false) return drawables;
foreach (var c in InternalChildren)
drawables.AddRange(c.Drawables);
return drawables;
}
}
public virtual void RemoveChild(CarouselItem i) public virtual void RemoveChild(CarouselItem i)
{ {
InternalChildren.Remove(i); InternalChildren.Remove(i);

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
namespace osu.Game.Screens.Select.Carousel namespace osu.Game.Screens.Select.Carousel
@ -54,6 +55,14 @@ namespace osu.Game.Screens.Select.Carousel
updateSelectedIndex(); updateSelectedIndex();
} }
public void AddChildren(IEnumerable<CarouselItem> items)
{
foreach (var i in items)
base.AddChild(i);
attemptSelection();
}
public override void AddChild(CarouselItem i) public override void AddChild(CarouselItem i)
{ {
base.AddChild(i); base.AddChild(i);

View File

@ -0,0 +1,114 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Select.Carousel
{
public class CarouselHeader : Container
{
private SampleChannel sampleHover;
private readonly Box hoverLayer;
public Container BorderContainer;
public readonly Bindable<CarouselItemState> State = new Bindable<CarouselItemState>(CarouselItemState.NotSelected);
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
public CarouselHeader()
{
RelativeSizeAxes = Axes.X;
Height = DrawableCarouselItem.MAX_HEIGHT;
InternalChild = BorderContainer = new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 10,
BorderColour = new Color4(221, 255, 255, 255),
Children = new Drawable[]
{
Content,
hoverLayer = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
Blending = BlendingParameters.Additive,
},
}
};
}
[BackgroundDependencyLoader]
private void load(AudioManager audio, OsuColour colours)
{
sampleHover = audio.Samples.Get($@"SongSelect/song-ping-variation-{RNG.Next(1, 5)}");
hoverLayer.Colour = colours.Blue.Opacity(0.1f);
}
protected override void LoadComplete()
{
base.LoadComplete();
State.BindValueChanged(updateState, true);
}
private void updateState(ValueChangedEvent<CarouselItemState> state)
{
switch (state.NewValue)
{
case CarouselItemState.Collapsed:
case CarouselItemState.NotSelected:
BorderContainer.BorderThickness = 0;
BorderContainer.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Offset = new Vector2(1),
Radius = 10,
Colour = Color4.Black.Opacity(100),
};
break;
case CarouselItemState.Selected:
BorderContainer.BorderThickness = 2.5f;
BorderContainer.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Glow,
Colour = new Color4(130, 204, 255, 150),
Radius = 20,
Roundness = 10,
};
break;
}
}
protected override bool OnHover(HoverEvent e)
{
sampleHover?.Play();
hoverLayer.FadeIn(100, Easing.OutQuint);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
hoverLayer.FadeOut(1000, Easing.OutQuint);
base.OnHoverLost(e);
}
}
}

View File

@ -2,13 +2,19 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using osu.Framework.Bindables; using osu.Framework.Bindables;
namespace osu.Game.Screens.Select.Carousel namespace osu.Game.Screens.Select.Carousel
{ {
public abstract class CarouselItem public abstract class CarouselItem : IComparable<CarouselItem>
{ {
public virtual float TotalHeight => 0;
/// <summary>
/// An externally defined value used to determine this item's vertical display offset relative to the carousel.
/// </summary>
public float CarouselYPosition;
public readonly BindableBool Filtered = new BindableBool(); public readonly BindableBool Filtered = new BindableBool();
public readonly Bindable<CarouselItemState> State = new Bindable<CarouselItemState>(CarouselItemState.NotSelected); public readonly Bindable<CarouselItemState> State = new Bindable<CarouselItemState>(CarouselItemState.NotSelected);
@ -18,23 +24,8 @@ namespace osu.Game.Screens.Select.Carousel
/// </summary> /// </summary>
public bool Visible => State.Value != CarouselItemState.Collapsed && !Filtered.Value; public bool Visible => State.Value != CarouselItemState.Collapsed && !Filtered.Value;
public virtual List<DrawableCarouselItem> Drawables
{
get
{
var items = new List<DrawableCarouselItem>();
var self = DrawableRepresentation.Value;
if (self?.IsPresent == true) items.Add(self);
return items;
}
}
protected CarouselItem() protected CarouselItem()
{ {
DrawableRepresentation = new Lazy<DrawableCarouselItem>(CreateDrawableRepresentation);
Filtered.ValueChanged += filtered => Filtered.ValueChanged += filtered =>
{ {
if (filtered.NewValue && State.Value == CarouselItemState.Selected) if (filtered.NewValue && State.Value == CarouselItemState.Selected)
@ -42,23 +33,23 @@ namespace osu.Game.Screens.Select.Carousel
}; };
} }
protected readonly Lazy<DrawableCarouselItem> DrawableRepresentation;
/// <summary> /// <summary>
/// Used as a default sort method for <see cref="CarouselItem"/>s of differing types. /// Used as a default sort method for <see cref="CarouselItem"/>s of differing types.
/// </summary> /// </summary>
internal ulong ChildID; internal ulong ChildID;
/// <summary> /// <summary>
/// Create a fresh drawable version of this item. If you wish to consume the current representation, use <see cref="DrawableRepresentation"/> instead. /// Create a fresh drawable version of this item.
/// </summary> /// </summary>
protected abstract DrawableCarouselItem CreateDrawableRepresentation(); public abstract DrawableCarouselItem CreateDrawableRepresentation();
public virtual void Filter(FilterCriteria criteria) public virtual void Filter(FilterCriteria criteria)
{ {
} }
public virtual int CompareTo(FilterCriteria criteria, CarouselItem other) => ChildID.CompareTo(other.ChildID); public virtual int CompareTo(FilterCriteria criteria, CarouselItem other) => ChildID.CompareTo(other.ChildID);
public int CompareTo(CarouselItem other) => CarouselYPosition.CompareTo(other.CarouselYPosition);
} }
public enum CarouselItemState public enum CarouselItemState

View File

@ -31,6 +31,15 @@ namespace osu.Game.Screens.Select.Carousel
{ {
public class DrawableCarouselBeatmap : DrawableCarouselItem, IHasContextMenu public class DrawableCarouselBeatmap : DrawableCarouselItem, IHasContextMenu
{ {
public const float CAROUSEL_BEATMAP_SPACING = 5;
/// <summary>
/// The height of a carousel beatmap, including vertical spacing.
/// </summary>
public const float HEIGHT = height + CAROUSEL_BEATMAP_SPACING;
private const float height = MAX_HEIGHT * 0.6f;
private readonly BeatmapInfo beatmap; private readonly BeatmapInfo beatmap;
private Sprite background; private Sprite background;
@ -58,15 +67,16 @@ namespace osu.Game.Screens.Select.Carousel
private CancellationTokenSource starDifficultyCancellationSource; private CancellationTokenSource starDifficultyCancellationSource;
public DrawableCarouselBeatmap(CarouselBeatmap panel) public DrawableCarouselBeatmap(CarouselBeatmap panel)
: base(panel)
{ {
beatmap = panel.Beatmap; beatmap = panel.Beatmap;
Height *= 0.60f; Item = panel;
} }
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
private void load(BeatmapManager manager, SongSelect songSelect) private void load(BeatmapManager manager, SongSelect songSelect)
{ {
Header.Height = height;
if (songSelect != null) if (songSelect != null)
{ {
startRequested = b => songSelect.FinaliseSelection(b); startRequested = b => songSelect.FinaliseSelection(b);
@ -77,7 +87,7 @@ namespace osu.Game.Screens.Select.Carousel
if (manager != null) if (manager != null)
hideRequested = manager.Hide; hideRequested = manager.Hide;
Children = new Drawable[] Header.Children = new Drawable[]
{ {
background = new Box background = new Box
{ {
@ -168,6 +178,8 @@ namespace osu.Game.Screens.Select.Carousel
{ {
base.Selected(); base.Selected();
MovementContainer.MoveToX(-50, 500, Easing.OutExpo);
background.Colour = ColourInfo.GradientVertical( background.Colour = ColourInfo.GradientVertical(
new Color4(20, 43, 51, 255), new Color4(20, 43, 51, 255),
new Color4(40, 86, 102, 255)); new Color4(40, 86, 102, 255));
@ -179,6 +191,8 @@ namespace osu.Game.Screens.Select.Carousel
{ {
base.Deselected(); base.Deselected();
MovementContainer.MoveToX(0, 500, Easing.OutExpo);
background.Colour = new Color4(20, 43, 51, 255); background.Colour = new Color4(20, 43, 51, 255);
triangles.Colour = OsuColour.Gray(0.5f); triangles.Colour = OsuColour.Gray(0.5f);
} }

View File

@ -3,32 +3,24 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Collections; using osu.Game.Collections;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Rulesets;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Select.Carousel namespace osu.Game.Screens.Select.Carousel
{ {
public class DrawableCarouselBeatmapSet : DrawableCarouselItem, IHasContextMenu public class DrawableCarouselBeatmapSet : DrawableCarouselItem, IHasContextMenu
{ {
public const float HEIGHT = MAX_HEIGHT;
private Action<BeatmapSetInfo> restoreHiddenRequested; private Action<BeatmapSetInfo> restoreHiddenRequested;
private Action<int> viewDetails; private Action<int> viewDetails;
@ -41,99 +33,139 @@ namespace osu.Game.Screens.Select.Carousel
[Resolved(CanBeNull = true)] [Resolved(CanBeNull = true)]
private ManageCollectionsDialog manageCollectionsDialog { get; set; } private ManageCollectionsDialog manageCollectionsDialog { get; set; }
private readonly BeatmapSetInfo beatmapSet; public IEnumerable<DrawableCarouselItem> DrawableBeatmaps => beatmapContainer?.Children ?? Enumerable.Empty<DrawableCarouselItem>();
public DrawableCarouselBeatmapSet(CarouselBeatmapSet set) private Container<DrawableCarouselItem> beatmapContainer;
: base(set)
private BeatmapSetInfo beatmapSet;
[Resolved]
private BeatmapManager manager { get; set; }
protected override void FreeAfterUse()
{ {
beatmapSet = set.BeatmapSet; base.FreeAfterUse();
Item = null;
ClearTransforms();
} }
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
private void load(BeatmapManager manager, BeatmapSetOverlay beatmapOverlay) private void load(BeatmapSetOverlay beatmapOverlay)
{ {
restoreHiddenRequested = s => s.Beatmaps.ForEach(manager.Restore); restoreHiddenRequested = s => s.Beatmaps.ForEach(manager.Restore);
if (beatmapOverlay != null) if (beatmapOverlay != null)
viewDetails = beatmapOverlay.FetchAndShowBeatmapSet; viewDetails = beatmapOverlay.FetchAndShowBeatmapSet;
}
Children = new Drawable[] protected override void UpdateItem()
{ {
new DelayedLoadUnloadWrapper(() => base.UpdateItem();
Content.Clear();
beatmapContainer = null;
if (Item == null)
return;
beatmapSet = ((CarouselBeatmapSet)Item).BeatmapSet;
DelayedLoadWrapper background;
DelayedLoadWrapper mainFlow;
Header.Children = new Drawable[]
{ {
var background = new PanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.FirstOrDefault())) background = new DelayedLoadWrapper(new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.FirstOrDefault()))
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
}, 300),
mainFlow = new DelayedLoadWrapper(new SetPanelContent((CarouselBeatmapSet)Item), 100),
}; };
background.OnLoadComplete += d => d.FadeInFromZero(1000, Easing.OutQuint); background.DelayedLoadComplete += fadeContentIn;
mainFlow.DelayedLoadComplete += fadeContentIn;
}
return background; private void fadeContentIn(Drawable d) => d.FadeInFromZero(750, Easing.OutQuint);
}, 300, 5000
), protected override void Deselected()
new FillFlowContainer
{ {
Direction = FillDirection.Vertical, base.Deselected();
Padding = new MarginPadding { Top = 5, Left = 18, Right = 10, Bottom = 10 },
AutoSizeAxes = Axes.Both, MovementContainer.MoveToX(0, 500, Easing.OutExpo);
Children = new Drawable[]
if (beatmapContainer != null)
{ {
new OsuSpriteText foreach (var beatmap in beatmapContainer)
{ beatmap.MoveToY(0, 800, Easing.OutQuint);
Text = new LocalisedString((beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title)),
Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 22, italics: true),
Shadow = true,
},
new OsuSpriteText
{
Text = new LocalisedString((beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist)),
Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 17, italics: true),
Shadow = true,
},
new FillFlowContainer
{
Direction = FillDirection.Horizontal,
AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Top = 5 },
Children = new Drawable[]
{
new BeatmapSetOnlineStatusPill
{
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Margin = new MarginPadding { Right = 5 },
TextSize = 11,
TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 },
Status = beatmapSet.Status
},
new FillFlowContainer<DifficultyIcon>
{
AutoSizeAxes = Axes.Both,
Spacing = new Vector2(3),
ChildrenEnumerable = getDifficultyIcons(),
},
} }
} }
protected override void Selected()
{
base.Selected();
MovementContainer.MoveToX(-100, 500, Easing.OutExpo);
updateBeatmapDifficulties();
} }
private void updateBeatmapDifficulties()
{
var carouselBeatmapSet = (CarouselBeatmapSet)Item;
var visibleBeatmaps = carouselBeatmapSet.Children.Where(c => c.Visible).ToArray();
// if we are already displaying all the correct beatmaps, only run animation updates.
// note that the displayed beatmaps may change due to the applied filter.
// a future optimisation could add/remove only changed difficulties rather than reinitialise.
if (beatmapContainer != null && visibleBeatmaps.Length == beatmapContainer.Count && visibleBeatmaps.All(b => beatmapContainer.Any(c => c.Item == b)))
{
updateBeatmapYPositions();
} }
else
{
// on selection we show our child beatmaps.
// for now this is a simple drawable construction each selection.
// can be improved in the future.
beatmapContainer = new Container<DrawableCarouselItem>
{
X = 100,
RelativeSizeAxes = Axes.Both,
ChildrenEnumerable = visibleBeatmaps.Select(c => c.CreateDrawableRepresentation())
}; };
LoadComponentAsync(beatmapContainer, loaded =>
{
// make sure the pooled target hasn't changed.
if (carouselBeatmapSet != Item)
return;
Content.Child = loaded;
updateBeatmapYPositions();
});
} }
private const int maximum_difficulty_icons = 18; void updateBeatmapYPositions()
private IEnumerable<DifficultyIcon> getDifficultyIcons()
{ {
var beatmaps = ((CarouselBeatmapSet)Item).Beatmaps.ToList(); float yPos = DrawableCarouselBeatmap.CAROUSEL_BEATMAP_SPACING;
return beatmaps.Count > maximum_difficulty_icons foreach (var panel in beatmapContainer.Children)
? (IEnumerable<DifficultyIcon>)beatmaps.GroupBy(b => b.Beatmap.Ruleset).Select(group => new FilterableGroupedDifficultyIcon(group.ToList(), group.Key)) {
: beatmaps.Select(b => new FilterableDifficultyIcon(b)); panel.MoveToY(yPos, 800, Easing.OutQuint);
yPos += panel.Item.TotalHeight;
}
}
} }
public MenuItem[] ContextMenuItems public MenuItem[] ContextMenuItems
{ {
get get
{ {
Debug.Assert(beatmapSet != null);
List<MenuItem> items = new List<MenuItem>(); List<MenuItem> items = new List<MenuItem>();
if (Item.State.Value == CarouselItemState.NotSelected) if (Item.State.Value == CarouselItemState.NotSelected)
@ -162,6 +194,8 @@ namespace osu.Game.Screens.Select.Carousel
private MenuItem createCollectionMenuItem(BeatmapCollection collection) private MenuItem createCollectionMenuItem(BeatmapCollection collection)
{ {
Debug.Assert(beatmapSet != null);
TernaryState state; TernaryState state;
var countExisting = beatmapSet.Beatmaps.Count(b => collection.Beatmaps.Contains(b)); var countExisting = beatmapSet.Beatmaps.Count(b => collection.Beatmaps.Contains(b));
@ -196,116 +230,5 @@ namespace osu.Game.Screens.Select.Carousel
State = { Value = state } State = { Value = state }
}; };
} }
private class PanelBackground : BufferedContainer
{
public PanelBackground(WorkingBeatmap working)
{
CacheDrawnFrameBuffer = true;
RedrawOnScale = false;
Children = new Drawable[]
{
new BeatmapBackgroundSprite(working)
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
FillMode = FillMode.Fill,
},
new FillFlowContainer
{
Depth = -1,
RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
// This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle
Shear = new Vector2(0.8f, 0),
Alpha = 0.5f,
Children = new[]
{
// The left half with no gradient applied
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.Black,
Width = 0.4f,
},
// Piecewise-linear gradient with 3 segments to make it appear smoother
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.9f)),
Width = 0.05f,
},
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.9f), new Color4(0f, 0f, 0f, 0.1f)),
Width = 0.2f,
},
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.1f), new Color4(0, 0, 0, 0)),
Width = 0.05f,
},
}
},
};
}
}
public class FilterableDifficultyIcon : DifficultyIcon
{
private readonly BindableBool filtered = new BindableBool();
public bool IsFiltered => filtered.Value;
public readonly CarouselBeatmap Item;
public FilterableDifficultyIcon(CarouselBeatmap item)
: base(item.Beatmap)
{
filtered.BindTo(item.Filtered);
filtered.ValueChanged += isFiltered => Schedule(() => this.FadeTo(isFiltered.NewValue ? 0.1f : 1, 100));
filtered.TriggerChange();
Item = item;
}
protected override bool OnClick(ClickEvent e)
{
Item.State.Value = CarouselItemState.Selected;
return true;
}
}
public class FilterableGroupedDifficultyIcon : GroupedDifficultyIcon
{
public readonly List<CarouselBeatmap> Items;
public FilterableGroupedDifficultyIcon(List<CarouselBeatmap> items, RulesetInfo ruleset)
: base(items.Select(i => i.Beatmap).ToList(), ruleset, Color4.White)
{
Items = items;
foreach (var item in items)
item.Filtered.BindValueChanged(_ => Scheduler.AddOnce(updateFilteredDisplay));
updateFilteredDisplay();
}
protected override bool OnClick(ClickEvent e)
{
Items.First().State.Value = CarouselItemState.Selected;
return true;
}
private void updateFilteredDisplay()
{
// for now, fade the whole group based on the ratio of hidden items.
this.FadeTo(1 - 0.9f * ((float)Items.Count(i => i.Filtered.Value) / Items.Count), 100);
}
}
} }
} }

View File

@ -1,106 +1,133 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation; using System.Diagnostics;
using osu.Framework.Audio; using osu.Framework.Bindables;
using osu.Framework.Audio.Sample;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Pooling;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osuTK; using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Select.Carousel namespace osu.Game.Screens.Select.Carousel
{ {
public abstract class DrawableCarouselItem : Container public abstract class DrawableCarouselItem : PoolableDrawable
{ {
public const float MAX_HEIGHT = 80; public const float MAX_HEIGHT = 80;
public override bool RemoveWhenNotAlive => false; public override bool IsPresent => base.IsPresent || Item?.Visible == true;
public override bool IsPresent => base.IsPresent || Item.Visible; public readonly CarouselHeader Header;
public readonly CarouselItem Item; /// <summary>
/// Optional content which sits below the header.
/// </summary>
protected readonly Container<Drawable> Content;
private Container nestedContainer; protected readonly Container MovementContainer;
private Container borderContainer;
private Box hoverLayer; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
Header.ReceivePositionalInputAt(screenSpacePos);
protected override Container<Drawable> Content => nestedContainer; private CarouselItem item;
protected DrawableCarouselItem(CarouselItem item) public CarouselItem Item
{ {
Item = item; get => item;
set
{
if (item == value)
return;
Height = MAX_HEIGHT; if (item != null)
RelativeSizeAxes = Axes.X; {
Alpha = 0; item.Filtered.ValueChanged -= onStateChange;
item.State.ValueChanged -= onStateChange;
Header.State.UnbindFrom(item.State);
if (item is CarouselGroup group)
{
foreach (var c in group.Children)
c.Filtered.ValueChanged -= onStateChange;
}
} }
private SampleChannel sampleHover; item = value;
[BackgroundDependencyLoader] if (IsLoaded)
private void load(AudioManager audio, OsuColour colours) UpdateItem();
}
}
protected DrawableCarouselItem()
{ {
InternalChild = borderContainer = new Container RelativeSizeAxes = Axes.X;
Alpha = 0;
InternalChildren = new Drawable[]
{
MovementContainer = new Container
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 10,
BorderColour = new Color4(221, 255, 255, 255),
Children = new Drawable[] Children = new Drawable[]
{ {
nestedContainer = new Container Header = new CarouselHeader(),
Content = new Container
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
},
hoverLayer = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
Blending = BlendingParameters.Additive,
},
} }
}
},
}; };
sampleHover = audio.Samples.Get($@"SongSelect/song-ping-variation-{RNG.Next(1, 5)}");
hoverLayer.Colour = colours.Blue.Opacity(0.1f);
} }
protected override bool OnHover(HoverEvent e) public void SetMultiplicativeAlpha(float alpha) => Header.BorderContainer.Alpha = alpha;
{
sampleHover?.Play();
hoverLayer.FadeIn(100, Easing.OutQuint);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
hoverLayer.FadeOut(1000, Easing.OutQuint);
base.OnHoverLost(e);
}
public void SetMultiplicativeAlpha(float alpha) => borderContainer.Alpha = alpha;
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
ApplyState(); UpdateItem();
Item.Filtered.ValueChanged += _ => Schedule(ApplyState);
Item.State.ValueChanged += _ => Schedule(ApplyState);
} }
protected override void Update()
{
base.Update();
Content.Y = Header.Height;
}
protected virtual void UpdateItem()
{
if (item == null)
return;
Scheduler.AddOnce(ApplyState);
Item.Filtered.ValueChanged += onStateChange;
Item.State.ValueChanged += onStateChange;
Header.State.BindTo(Item.State);
if (Item is CarouselGroup group)
{
foreach (var c in group.Children)
c.Filtered.ValueChanged += onStateChange;
}
}
private void onStateChange(ValueChangedEvent<CarouselItemState> obj) => Scheduler.AddOnce(ApplyState);
private void onStateChange(ValueChangedEvent<bool> _) => Scheduler.AddOnce(ApplyState);
protected virtual void ApplyState() protected virtual void ApplyState()
{ {
if (!IsLoaded) return; // Use the fact that we know the precise height of the item from the model to avoid the need for AutoSize overhead.
// Additionally, AutoSize doesn't work well due to content starting off-screen and being masked away.
Height = Item.TotalHeight;
Debug.Assert(Item != null);
switch (Item.State.Value) switch (Item.State.Value)
{ {
@ -121,30 +148,11 @@ namespace osu.Game.Screens.Select.Carousel
protected virtual void Selected() protected virtual void Selected()
{ {
Item.State.Value = CarouselItemState.Selected; Debug.Assert(Item != null);
borderContainer.BorderThickness = 2.5f;
borderContainer.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Glow,
Colour = new Color4(130, 204, 255, 150),
Radius = 20,
Roundness = 10,
};
} }
protected virtual void Deselected() protected virtual void Deselected()
{ {
Item.State.Value = CarouselItemState.NotSelected;
borderContainer.BorderThickness = 0;
borderContainer.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Offset = new Vector2(1),
Radius = 10,
Colour = Color4.Black.Opacity(100),
};
} }
protected override bool OnClick(ClickEvent e) protected override bool OnClick(ClickEvent e)

View File

@ -0,0 +1,35 @@
// 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 osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps.Drawables;
namespace osu.Game.Screens.Select.Carousel
{
public class FilterableDifficultyIcon : DifficultyIcon
{
private readonly BindableBool filtered = new BindableBool();
public bool IsFiltered => filtered.Value;
public readonly CarouselBeatmap Item;
public FilterableDifficultyIcon(CarouselBeatmap item)
: base(item.Beatmap)
{
filtered.BindTo(item.Filtered);
filtered.ValueChanged += isFiltered => Schedule(() => this.FadeTo(isFiltered.NewValue ? 0.1f : 1, 100));
filtered.TriggerChange();
Item = item;
}
protected override bool OnClick(ClickEvent e)
{
Item.State.Value = CarouselItemState.Selected;
return true;
}
}
}

View File

@ -0,0 +1,41 @@
// 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.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Rulesets;
using osuTK.Graphics;
namespace osu.Game.Screens.Select.Carousel
{
public class FilterableGroupedDifficultyIcon : GroupedDifficultyIcon
{
public readonly List<CarouselBeatmap> Items;
public FilterableGroupedDifficultyIcon(List<CarouselBeatmap> items, RulesetInfo ruleset)
: base(items.Select(i => i.Beatmap).ToList(), ruleset, Color4.White)
{
Items = items;
foreach (var item in items)
item.Filtered.BindValueChanged(_ => Scheduler.AddOnce(updateFilteredDisplay));
updateFilteredDisplay();
}
protected override bool OnClick(ClickEvent e)
{
Items.First().State.Value = CarouselItemState.Selected;
return true;
}
private void updateFilteredDisplay()
{
// for now, fade the whole group based on the ratio of hidden items.
this.FadeTo(1 - 0.9f * ((float)Items.Count(i => i.Filtered.Value) / Items.Count), 100);
}
}
}

View File

@ -0,0 +1,72 @@
// 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 osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Select.Carousel
{
public class SetPanelBackground : BufferedContainer
{
public SetPanelBackground(WorkingBeatmap working)
{
CacheDrawnFrameBuffer = true;
RedrawOnScale = false;
Children = new Drawable[]
{
new BeatmapBackgroundSprite(working)
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
FillMode = FillMode.Fill,
},
new FillFlowContainer
{
Depth = -1,
RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
// This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle
Shear = new Vector2(0.8f, 0),
Alpha = 0.5f,
Children = new[]
{
// The left half with no gradient applied
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.Black,
Width = 0.4f,
},
// Piecewise-linear gradient with 3 segments to make it appear smoother
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.9f)),
Width = 0.05f,
},
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.9f), new Color4(0f, 0f, 0f, 0.1f)),
Width = 0.2f,
},
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.1f), new Color4(0, 0, 0, 0)),
Width = 0.05f,
},
}
},
};
}
}
}

View File

@ -0,0 +1,93 @@
// 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.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Localisation;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osuTK;
namespace osu.Game.Screens.Select.Carousel
{
public class SetPanelContent : CompositeDrawable
{
private readonly CarouselBeatmapSet carouselSet;
public SetPanelContent(CarouselBeatmapSet carouselSet)
{
this.carouselSet = carouselSet;
// required to ensure we load as soon as any part of the panel comes on screen
RelativeSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load()
{
var beatmapSet = carouselSet.BeatmapSet;
InternalChild = new FillFlowContainer
{
// required to ensure we load as soon as any part of the panel comes on screen
RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Padding = new MarginPadding { Top = 5, Left = 18, Right = 10, Bottom = 10 },
Children = new Drawable[]
{
new OsuSpriteText
{
Text = new LocalisedString((beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title)),
Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 22, italics: true),
Shadow = true,
},
new OsuSpriteText
{
Text = new LocalisedString((beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist)),
Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 17, italics: true),
Shadow = true,
},
new FillFlowContainer
{
Direction = FillDirection.Horizontal,
AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Top = 5 },
Children = new Drawable[]
{
new BeatmapSetOnlineStatusPill
{
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Margin = new MarginPadding { Right = 5 },
TextSize = 11,
TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 },
Status = beatmapSet.Status
},
new FillFlowContainer<DifficultyIcon>
{
AutoSizeAxes = Axes.Both,
Spacing = new Vector2(3),
ChildrenEnumerable = getDifficultyIcons(),
},
}
}
}
};
}
private const int maximum_difficulty_icons = 18;
private IEnumerable<DifficultyIcon> getDifficultyIcons()
{
var beatmaps = carouselSet.Beatmaps.ToList();
return beatmaps.Count > maximum_difficulty_icons
? (IEnumerable<DifficultyIcon>)beatmaps.GroupBy(b => b.Beatmap.Ruleset).Select(group => new FilterableGroupedDifficultyIcon(group.ToList(), group.Key))
: beatmaps.Select(b => new FilterableDifficultyIcon(b));
}
}
}