1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-28 03:53:45 +08:00

Merge pull request #33264 from peppy/song-select-v2-hookup

SongSelectV2: Hook up screen to carousel and move selection logic up one level
This commit is contained in:
Dean Herbert
2025-05-26 23:49:54 +09:00
committed by GitHub
Unverified
9 changed files with 217 additions and 62 deletions
@@ -107,8 +107,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
Carousel = new TestBeatmapCarousel
{
NewItemsPresented = () => NewItemsPresentedInvocationCount++,
ChooseRecommendedBeatmap = beatmaps => BeatmapRecommendationFunction?.Invoke(beatmaps) ?? beatmaps.First(),
NewItemsPresented = _ => NewItemsPresentedInvocationCount++,
RequestSelection = b => Carousel.CurrentSelection = b,
RequestRecommendedSelection = beatmaps => Carousel.CurrentSelection = BeatmapRecommendationFunction?.Invoke(beatmaps) ?? beatmaps.First(),
BleedTop = 50,
BleedBottom = 50,
Anchor = Anchor.Centre,
@@ -8,6 +8,7 @@ using NUnit.Framework;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Online.API;
using osu.Game.Overlays.Dialog;
using osu.Game.Overlays.Mods;
@@ -36,10 +37,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
AddAssert("beatmap imported", () => Beatmaps.GetAllUsableBeatmapSets().Any(), () => Is.True);
// song select should automatically select the beatmap for us but this is not implemented yet.
// todo: remove when that's the case.
AddAssert("no beatmap selected", () => Beatmap.IsDefault);
AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First()));
AddAssert("beatmap selected", () => !Beatmap.IsDefault);
AddStep("import score", () =>
@@ -90,11 +87,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
ImportBeatmapForRuleset(0);
AddAssert("beatmap imported", () => Beatmaps.GetAllUsableBeatmapSets().Any(), () => Is.True);
// song select should automatically select the beatmap for us but this is not implemented yet.
// todo: remove when that's the case.
AddAssert("no beatmap selected", () => Beatmap.IsDefault);
AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First()));
AddAssert("beatmap selected", () => !Beatmap.IsDefault);
AddStep("press shift-delete", () =>
@@ -253,11 +245,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
ImportBeatmapForRuleset(0);
LoadSongSelect();
// song select should automatically select the beatmap for us but this is not implemented yet.
// todo: remove when that's the case.
AddAssert("no beatmap selected", () => Beatmap.IsDefault);
AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First()));
AddStep("press right", () => InputManager.Key(Key.Right)); // press right to select in carousel, also remove.
AddAssert("beatmap selected", () => !Beatmap.IsDefault);
@@ -283,11 +270,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
ImportBeatmapForRuleset(0);
LoadSongSelect();
// song select should automatically select the beatmap for us but this is not implemented yet.
// todo: remove when that's the case.
AddAssert("no beatmap selected", () => Beatmap.IsDefault);
AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First()));
AddStep("press right", () => InputManager.Key(Key.Right)); // press right to select in carousel, also remove.
AddAssert("beatmap selected", () => !Beatmap.IsDefault);
@@ -315,11 +297,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
ImportBeatmapForRuleset(0);
LoadSongSelect();
// song select should automatically select the beatmap for us but this is not implemented yet.
// todo: remove when that's the case.
AddAssert("no beatmap selected", () => Beatmap.IsDefault);
AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First()));
AddStep("press right", () => InputManager.Key(Key.Right)); // press right to select in carousel, also remove.
AddAssert("beatmap selected", () => !Beatmap.IsDefault);
@@ -460,17 +437,56 @@ namespace osu.Game.Tests.Visual.SongSelectV2
LoadSongSelect();
ImportBeatmapForRuleset(0);
// song select should automatically select the beatmap for us but this is not implemented yet.
// todo: remove when that's the case.
AddAssert("no beatmap selected", () => Beatmap.IsDefault);
AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First()));
AddAssert("options enabled", () => this.ChildrenOfType<FooterButtonOptions>().Single().Enabled.Value);
AddStep("click", () => this.ChildrenOfType<FooterButtonOptions>().Single().TriggerClick());
AddUntilStep("popover displayed", () => this.ChildrenOfType<FooterButtonOptions.Popover>().Any(p => p.IsPresent));
}
[Test]
public void TestSelectionChangedFromProtectedToNone()
{
ImportBeatmapForRuleset(0);
AddStep("set protected on import", () => Realm.Write(r => r.All<BeatmapSetInfo>().First(s => !s.DeletePending).Protected = true));
AddStep("selected protected", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().First(s => s.Protected).Beatmaps.First()));
LoadSongSelect();
AddUntilStep("beatmap deselected", () => Beatmap.IsDefault);
}
[Test]
public void TestSelectionChangedFromProtectedToSomething()
{
ImportBeatmapForRuleset(0);
AddStep("set protected on import", () => Realm.Write(r => r.All<BeatmapSetInfo>().First(s => !s.DeletePending).Protected = true));
AddStep("selected protected", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().First(s => s.Protected).Beatmaps.First()));
ImportBeatmapForRuleset(0);
LoadSongSelect();
AddUntilStep("beatmap selected", () => !Beatmap.IsDefault);
AddUntilStep("selection not protected", () => !Beatmap.Value.BeatmapSetInfo.Protected);
}
[Test]
public void TestSelectAfterDeletion()
{
LoadSongSelect();
ImportBeatmapForRuleset(0);
AddUntilStep("beatmap selected", () => !Beatmap.IsDefault);
AddStep("delete all beatmaps", () => Beatmaps.Delete());
AddUntilStep("beatmap not selected", () => Beatmap.IsDefault);
AddStep("restore deleted", () => Beatmaps.UndeleteAll());
AddUntilStep("beatmap selected", () => !Beatmap.IsDefault);
}
[Test]
public void TestFooterOptionsState()
{
@@ -478,16 +494,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2
ImportBeatmapForRuleset(0);
// song select should automatically select the beatmap for us but this is not implemented yet.
// todo: remove when that's the case.
AddAssert("no beatmap selected", () => Beatmap.IsDefault);
AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First()));
AddAssert("options enabled", () => this.ChildrenOfType<FooterButtonOptions>().Single().Enabled.Value);
AddStep("delete all beatmaps", () => Beatmaps.Delete());
// song select should automatically select the beatmap for us but this is not implemented yet.
// todo: remove when that's the case.
AddAssert("beatmap selected", () => !Beatmap.IsDefault);
AddStep("select no beatmap", () => Beatmap.SetDefault());
@@ -254,11 +254,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
checkMatchedBeatmaps(3);
// song select should automatically select the beatmap for us but this is not implemented yet.
// todo: remove when that's the case.
AddAssert("no beatmap selected", () => Beatmap.IsDefault);
AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First()));
AddStep("hide", () => Beatmaps.Hide(Beatmap.Value.BeatmapInfo));
checkMatchedBeatmaps(2);
+6
View File
@@ -90,6 +90,12 @@ namespace osu.Game.Beatmaps
return ID == other.ID;
}
public override int GetHashCode()
{
// ReSharper disable once NonReadonlyMemberInGetHashCode
return ID.GetHashCode();
}
public override string ToString() => Metadata.GetDisplayString();
public bool Equals(IBeatmapSetInfo? other) => other is BeatmapSetInfo b && Equals(b);
+2 -2
View File
@@ -39,7 +39,7 @@ namespace osu.Game.Graphics.Carousel
/// <summary>
/// Called after a filter operation or change in items results in the visible carousel items changing.
/// </summary>
public Action? NewItemsPresented { private get; init; }
public Action<IEnumerable<CarouselItem>>? NewItemsPresented { private get; init; }
/// <summary>
/// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it.
@@ -318,7 +318,7 @@ namespace osu.Game.Graphics.Carousel
if (!Scroll.UserScrolling)
scrollToSelection();
NewItemsPresented?.Invoke();
NewItemsPresented?.Invoke(carouselItems);
});
return items;
+10 -5
View File
@@ -27,9 +27,14 @@ namespace osu.Game.Screens.SelectV2
public Action<BeatmapInfo>? RequestPresentBeatmap { private get; init; }
/// <summary>
/// From the provided beatmaps, return the most appropriate one for the user's skill.
/// From the provided beatmaps, select the most appropriate one for the user's skill.
/// </summary>
public Func<IEnumerable<BeatmapInfo>, BeatmapInfo>? ChooseRecommendedBeatmap { private get; init; }
public required Action<IEnumerable<BeatmapInfo>> RequestRecommendedSelection { private get; init; }
/// <summary>
/// Selection requested for the provided beatmap.
/// </summary>
public required Action<BeatmapInfo> RequestSelection { private get; init; }
public const float SPACING = 3f;
@@ -139,7 +144,7 @@ namespace osu.Game.Screens.SelectV2
// TODO: should this exist in song select instead of here?
// we need to ensure the global beatmap is also updated alongside changes.
if (CurrentSelection != null && CheckModelEquality(beatmap, CurrentSelection))
CurrentSelection = matchingNewBeatmap;
RequestSelection(matchingNewBeatmap);
Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]);
newSetBeatmaps.Remove(matchingNewBeatmap);
@@ -190,7 +195,7 @@ namespace osu.Game.Screens.SelectV2
if (grouping.SetItems.TryGetValue(setInfo, out var items))
{
var beatmaps = items.Select(i => i.Model).OfType<BeatmapInfo>();
CurrentSelection = ChooseRecommendedBeatmap?.Invoke(beatmaps) ?? beatmaps.First();
RequestRecommendedSelection(beatmaps);
}
return;
@@ -202,7 +207,7 @@ namespace osu.Game.Screens.SelectV2
return;
}
CurrentSelection = beatmapInfo;
RequestSelection(beatmapInfo);
return;
}
}
+1 -1
View File
@@ -209,7 +209,7 @@ namespace osu.Game.Screens.SelectV2
var beatmap = (BeatmapInfo)Item.Model;
starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, 200);
starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, SongSelect.SELECTION_DEBOUNCE);
starDifficultyBindable.BindValueChanged(_ => updateDisplay(), true);
}
@@ -245,7 +245,7 @@ namespace osu.Game.Screens.SelectV2
var beatmap = (BeatmapInfo)Item.Model;
starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, 200);
starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, SongSelect.SELECTION_DEBOUNCE);
starDifficultyBindable.BindValueChanged(_ => updateDisplay(), true);
}
+149 -10
View File
@@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
@@ -15,10 +16,12 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Graphics.Carousel;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
@@ -32,6 +35,7 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Screens.Footer;
using osu.Game.Screens.Menu;
using osu.Game.Screens.Play;
using osu.Game.Screens.Ranking;
using osu.Game.Screens.Select;
using osu.Game.Skinning;
@@ -47,8 +51,12 @@ namespace osu.Game.Screens.SelectV2
/// This will be gradually built upon and ultimately replace <see cref="Select.SongSelect"/> once everything is in place.
/// </summary>
[Cached(typeof(ISongSelect))]
public abstract partial class SongSelect : OsuScreen, IKeyBindingHandler<GlobalAction>, ISongSelect
public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler<GlobalAction>, ISongSelect
{
// this is intentionally slightly higher than key repeat, but low enough to not impede user experience.
// this avoids rapid churn loading when iterating the carousel using keyboard.
public const int SELECTION_DEBOUNCE = 100;
private const float logo_scale = 0.4f;
private const double fade_duration = 300;
@@ -56,6 +64,12 @@ namespace osu.Game.Screens.SelectV2
public const float CORNER_RADIUS_HIDE_OFFSET = 20f;
public const float ENTER_DURATION = 600;
/// <summary>
/// Whether this song select instance should take control of the global track,
/// applying looping and preview offsets.
/// </summary>
protected bool ControlGlobalMusic { get; init; } = true;
private readonly ModSelectOverlay modSelectOverlay = new UserModSelectOverlay(OverlayColourScheme.Aquamarine)
{
ShowPresets = true,
@@ -177,9 +191,11 @@ namespace osu.Game.Screens.SelectV2
{
BleedTop = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5,
BleedBottom = ScreenFooter.HEIGHT + 5,
RequestPresentBeatmap = SelectAndStart,
NewItemsPresented = newItemsPresented,
RelativeSizeAxes = Axes.Both,
RequestPresentBeatmap = _ => OnStart(),
RequestSelection = selectBeatmap,
RequestRecommendedSelection = selectRecommendedBeatmap,
NewItemsPresented = newItemsPresented,
},
noResultsPlaceholder = new NoResultsPlaceholder(),
}
@@ -245,10 +261,107 @@ namespace osu.Game.Screens.SelectV2
detailsArea.Height = wedgesContainer.DrawHeight - titleWedge.LayoutSize.Y - 4;
}
#region Audio
[Resolved]
private MusicController music { get; set; } = null!;
private readonly WeakReference<ITrack?> lastTrack = new WeakReference<ITrack?>(null);
/// <summary>
/// Ensures some music is playing for the current track.
/// Will resume playback from a manual user pause if the track has changed.
/// </summary>
private void ensurePlayingSelected()
{
if (!ControlGlobalMusic)
return;
ITrack track = music.CurrentTrack;
bool isNewTrack = !lastTrack.TryGetTarget(out var last) || last != track;
if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack))
{
Logger.Log($"Song select decided to {nameof(ensurePlayingSelected)}");
music.Play(true);
}
lastTrack.SetTarget(track);
}
private bool isHandlingLooping;
private void beginLooping()
{
if (!ControlGlobalMusic)
return;
Debug.Assert(!isHandlingLooping);
isHandlingLooping = true;
ensureTrackLooping(Beatmap.Value, TrackChangeDirection.None);
music.TrackChanged += ensureTrackLooping;
}
private void endLooping()
{
// may be called multiple times during screen exit process.
if (!isHandlingLooping)
return;
music.CurrentTrack.Looping = isHandlingLooping = false;
music.TrackChanged -= ensureTrackLooping;
}
private void ensureTrackLooping(IWorkingBeatmap beatmap, TrackChangeDirection changeDirection)
=> beatmap.PrepareTrackForPreview(true);
#endregion
#region Selection handling
private BeatmapInfo getRecommendedBeatmap(IEnumerable<BeatmapInfo> beatmaps)
=> difficultyRecommender?.GetRecommendedBeatmap(beatmaps) ?? beatmaps.First();
private ScheduledDelegate? selectionDebounce;
private void selectRecommendedBeatmap(IEnumerable<BeatmapInfo> beatmaps)
{
selectBeatmap(difficultyRecommender?.GetRecommendedBeatmap(beatmaps) ?? beatmaps.First());
}
private void selectBeatmap(BeatmapInfo beatmap)
{
carousel.CurrentSelection = beatmap;
selectionDebounce?.Cancel();
selectionDebounce = Scheduler.AddDelayed(() => selectBeatmap(beatmaps.GetWorkingBeatmap(beatmap)), SELECTION_DEBOUNCE);
}
private void selectBeatmap(WorkingBeatmap beatmap)
{
if (beatmap.BeatmapInfo.BeatmapSet!.Protected)
return;
carousel.CurrentSelection = beatmap.BeatmapInfo;
Beatmap.Value = beatmap;
if (this.IsCurrentScreen())
ensurePlayingSelected();
// If not the current screen, this will be applied in OnResuming.
if (this.IsCurrentScreen())
{
ApplyToBackground(backgroundModeBeatmap =>
{
backgroundModeBeatmap.Beatmap = beatmap;
backgroundModeBeatmap.IgnoreUserSettings.Value = true;
backgroundModeBeatmap.FadeColour(Color4.White, 250);
});
}
}
#endregion
@@ -266,6 +379,14 @@ namespace osu.Game.Screens.SelectV2
modSelectOverlay.Beatmap.BindTo(Beatmap);
modSelectOverlay.SelectedMods.BindTo(Mods);
beginLooping();
// force reselection if entering song select with a protected beatmap
if (Beatmap.Value.BeatmapInfo.BeatmapSet!.Protected)
Beatmap.SetDefault();
else
selectBeatmap(Beatmap.Value);
}
public override void OnResuming(ScreenTransitionEvent e)
@@ -285,6 +406,13 @@ namespace osu.Game.Screens.SelectV2
// required due to https://github.com/ppy/osu-framework/issues/3218
modSelectOverlay.SelectedMods.Disabled = false;
modSelectOverlay.SelectedMods.BindTo(Mods);
beginLooping();
if (Beatmap.Value.BeatmapInfo.BeatmapSet!.Protected)
Beatmap.SetDefault();
else
selectBeatmap(Beatmap.Value);
}
public override void OnSuspending(ScreenTransitionEvent e)
@@ -300,6 +428,8 @@ namespace osu.Game.Screens.SelectV2
carousel.VisuallyFocusSelected = true;
endLooping();
base.OnSuspending(e);
}
@@ -311,6 +441,8 @@ namespace osu.Game.Screens.SelectV2
detailsArea.Hide();
filterControl.Hide();
endLooping();
return base.OnExiting(e);
}
@@ -368,13 +500,10 @@ namespace osu.Game.Screens.SelectV2
private void criteriaChanged(FilterCriteria criteria)
{
filterDebounce?.Cancel();
filterDebounce = Scheduler.AddDelayed(() =>
{
carousel.Filter(criteria);
}, filter_delay);
filterDebounce = Scheduler.AddDelayed(() => { carousel.Filter(criteria); }, filter_delay);
}
private void newItemsPresented()
private void newItemsPresented(IEnumerable<CarouselItem> carouselItems)
{
int count = carousel.MatchedBeatmapsCount;
@@ -389,6 +518,16 @@ namespace osu.Game.Screens.SelectV2
// Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918
// but also in this case we want support for formatting a number within a string).
filterControl.StatusText = count != 1 ? $"{count:#,0} matches" : $"{count:#,0} match";
if (!carouselItems.Any())
{
Beatmap.SetDefault();
return;
}
if (Beatmap.IsDefault || Beatmap.Value.BeatmapSetInfo?.DeletePending == true)
// TODO: this should probably use random, not recommended like this.
selectRecommendedBeatmap(carouselItems.Select(i => i.Model).OfType<BeatmapInfo>());
}
#endregion