1
0
mirror of https://github.com/ppy/osu.git synced 2026-06-04 02:05:03 +08:00
Files
osu-lazer/osu.Game/Screens/Select/SongSelect.cs
T
Bartłomiej Dach 9727d95ad9 Replace usages of Mod.ScoreMultiplier with new score multiplier API (#37845)
- Part of https://github.com/ppy/osu/issues/37818

During review, I would like to direct particular attention to the
following changes:

## [Migrate song select to new score multiplier
API](https://github.com/ppy/osu/commit/945fd78539da3ae57d1550a5bbfb0f859d153cc4)

This was a confusing change to write because of the way song selects
hook their mod overlays up to global bindables. In particular different
things happen in different circumstances.

- When going through `SongSelect.CreateModOverlay()`, which is called by
the base `SongSelect`, the mod overlay is automatically bound to global
bindables via `SongSelect.on{ArrivingAt,Leaving}Screen()`.
- For multiplayer user mod select overlays, which are bolted on by
subclasses of `SongSelect`, manual hook-up is required.
- As for free mod select overlays, they don't show mod multipliers at
all, and don't have easy access to the ruleset, and thus the hookup is
skipped entirely as redundant.

## [Fix score multiplier registrations being shared between
implementations via superclass static
fields](https://github.com/ppy/osu/commit/ba0a7ad421e0c84c2d8162b6bbdd3a0683f5a6a6)

Revealed by `ScoreMultiplierCalculatorTest` starting to fail due to
interference from `OsuScoreMultiplierCalculator`.

It's not ideal from a performance standpoint but it's the simplest
choice for now. Tricks could be pulled to salvage the static. One is

```csharp
public class ScoreMultiplierCalculator<T>
	where T : ScoreMultiplierCalculator<T>
{
}
```

This works because of generics internals; static instance members are
not shared between different specialisations of a generic class. It is
also very unintuitive, so I would rather not. (It trips a ReSharper
inspection too, which would have to be silenced.)

From a performance standpoint this is not ideal, but a significant chunk
of migrated usages already precede the construction of the calculator
via the known-expensive `RulesetInfo.CreateInstance()`, and the paths
that actually construct the calculator do not appear to be that hot. If
need be, this can be handled by actually caching ruleset instances and
their derivative subcomponents.

## [Introduce passing of context to score multiplier
calculator](https://github.com/ppy/osu/pull/37845/changes/9e9242b3221dddacd226f4b3b9c5632d7350e998)

This is required for two reasons:

- The upcoming mod rebalance will require out-of-band supplementary
information that is not available for reading from the mod instances
themselves for calculating the multiplier.
- This context, namely passing of `ScoreInfo`, will be used for
implementing backwards compatibility with old scores and their score
multipliers. This is required because it has turned out under inspection
that all server-side lazer replays recorded until now are missing
`TotalScoreWithoutMods` due to an omission of not sending it across the
wire to spectator server.

Because the score import flow uses replays, filtered through
`LegacyScoreDecoder`, to populate total score in the realm database, it
is basically impossible to ignore scores that are missing
`TotalScoreWithoutMods`, because that will result in bug reports that
the scores do not have the new score multipliers applied.

Thus, passing of `ScoreInfo` will facilitate implementation of
versioning score multipliers, which should result in less breakage than
not doing so.

An example of this is added in 341b2d6e55,
which should handle the case of mania mod multipliers having been
changed without any attempt to facilitate for it in
https://github.com/ppy/osu/pull/30506.

---------

Co-authored-by: Dean Herbert <pe@ppy.sh>
2026-05-26 18:02:15 +09:00

1297 lines
50 KiB
C#

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Extensions;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Input.StateChanges;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics.Carousel;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods;
using osu.Game.Overlays.Volume;
using osu.Game.Rulesets;
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.Skinning;
using osu.Game.Utils;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Screens.Select
{
public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler<GlobalAction>, ISongSelect, IHandlePresentBeatmap, IProvideCursor
{
/// <summary>
/// A debounce that governs how long after a panel is selected before the rest of song select (and the game at large)
/// updates to show that selection.
///
/// This is intentionally slightly higher than key repeat, but low enough to not impede user experience.
/// </summary>
public const int SELECTION_DEBOUNCE = 150;
/// <summary>
/// A general "global" debounce to be applied to anything aggressive difficulty calculation at song select,
/// either after selection or after a panel comes on screen. Value should be low enough that users don't complain,
/// but otherwise as high as possible to reduce overheads.
/// </summary>
public const int DIFFICULTY_CALCULATION_DEBOUNCE = 150;
private const float logo_scale = 0.4f;
private const double fade_duration = 300;
public const float WEDGE_CONTENT_MARGIN = CORNER_RADIUS_HIDE_OFFSET + OsuGame.SCREEN_EDGE_MARGIN;
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;
/// <summary>
/// Whether this song select instance should allow scoping down to a specific beatmap set,
/// exposing other difficulties that are otherwise hidden by filter criteria.
/// </summary>
protected bool SupportScoping { init => scopedBeatmapSet.Disabled = !value; }
/// <summary>
/// Whether the osu! logo should be shown at the bottom-right of the screen.
/// </summary>
protected bool ShowOsuLogo { get; init; } = true;
/// <summary>
/// Additional padding to be added to the title wedge.
/// Generally set to show external content in this space.
/// </summary>
public float TopPadding { get; init; }
private ModSelectOverlay modSelectOverlay = null!;
private ModSpeedHotkeyHandler modSpeedHotkeyHandler = null!;
// Blue is the most neutral choice, so I'm using that for now.
// Purple makes the most sense to match the "gameplay" flow, but it's a bit too strong for the current design.
// TODO: Colour scheme choice should probably be customisable by the user.
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue);
private BeatmapCarousel carousel = null!;
protected FilterControl FilterControl { get; private set; } = null!;
private BeatmapTitleWedge titleWedge = null!;
private BeatmapDetailsArea detailsArea = null!;
private FillFlowContainer wedgesContainer = null!;
private Box rightGradientBackground = null!;
private Container mainContent = null!;
private SkinnableContainer skinnableContent = null!;
private GridContainer mainGridContainer = null!;
private NoResultsPlaceholder noResultsPlaceholder = null!;
public override bool? ApplyModTrackAdjustments => true;
public override bool ShowFooter => true;
private Sample? errorSample;
[Resolved]
private OsuGameBase? game { get; set; }
[Resolved]
private OsuLogo? logo { get; set; }
[Resolved]
private BeatmapSetOverlay? beatmapOverlay { get; set; }
[Resolved]
private BeatmapManager beatmaps { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private ManageCollectionsDialog? collectionsDialog { get; set; }
[Resolved]
private DifficultyRecommender? difficultyRecommender { get; set; }
[Resolved]
private IDialogOverlay? dialogOverlay { get; set; }
[Resolved]
private IOverlayManager? overlayManager { get; set; }
private InputManager inputManager = null!;
private readonly RealmPopulatingOnlineLookupSource onlineLookupSource = new RealmPopulatingOnlineLookupSource();
private Bindable<bool> configBackgroundBlur = null!;
private Bindable<bool> showConvertedBeatmaps = null!;
private IDisposable? modSelectOverlayRegistration;
[BackgroundDependencyLoader]
private void load(AudioManager audio, OsuConfigManager config)
{
errorSample = audio.Samples.Get(@"UI/generic-error");
AddRangeInternal(new Drawable[]
{
new GlobalScrollAdjustsVolume(),
onlineLookupSource,
mainContent = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Bottom = ScreenFooter.HEIGHT },
Child = new OsuContextMenuContainer
{
RelativeSizeAxes = Axes.Both,
Child = new PopoverContainer
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Width = 0.6f,
Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.3f), Color4.Black.Opacity(0f)),
},
mainGridContainer = new GridContainer // used for max width implementation
{
RelativeSizeAxes = Axes.Both,
Content = new[]
{
new[]
{
new Container
{
RelativeSizeAxes = Axes.Both,
// Ensure the left components are on top of the carousel both visually (although they should never overlay)
// but more importantly, for input purposes to allow the scroll-to-selection logic to override carousel's
// screen-wide scroll handling.
Depth = float.MinValue,
Shear = OsuGame.SHEAR,
Padding = new MarginPadding
{
Top = -CORNER_RADIUS_HIDE_OFFSET,
Left = -CORNER_RADIUS_HIDE_OFFSET,
},
Children = new Drawable[]
{
new Container
{
// Pad enough to only reset scroll when well into the left wedge areas.
Padding = new MarginPadding { Right = 40 },
RelativeSizeAxes = Axes.Both,
Child = new LeftSideInteractionContainer(() => carousel.ScrollToSelection())
{
RelativeSizeAxes = Axes.Both,
},
},
wedgesContainer = new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Spacing = new Vector2(0f, 4f),
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
new ShearAligningWrapper(titleWedge = new BeatmapTitleWedge
{
TopPadding = TopPadding,
}),
new ShearAligningWrapper(detailsArea = new BeatmapDetailsArea()),
},
},
}
},
Empty(),
new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
rightGradientBackground = new Box
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.0f), Color4.Black.Opacity(0.5f)),
RelativeSizeAxes = Axes.Both,
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding
{
Top = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5,
Bottom = 5,
},
Children = new Drawable[]
{
carousel = new BeatmapCarousel
{
BleedTop = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5,
BleedBottom = ScreenFooter.HEIGHT + 5,
RelativeSizeAxes = Axes.Both,
RequestPresentBeatmap = b => SelectAndRun(b, OnStart),
RequestSelection = queueBeatmapSelection,
RequestRecommendedSelection = requestRecommendedSelection,
NewItemsPresented = newItemsPresented,
},
noResultsPlaceholder = new NoResultsPlaceholder
{
RequestClearFilterText = () => FilterControl.Search(string.Empty)
}
}
},
FilterControl = new FilterControl
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
RelativeSizeAxes = Axes.X,
ScopedBeatmapSet = { BindTarget = ScopedBeatmapSet },
},
}
},
},
}
},
}
},
}
},
skinnableContent = new SkinnableContainer(new GlobalSkinnableContainerLookup(GlobalSkinnableContainers.SongSelect))
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
},
modSpeedHotkeyHandler = new ModSpeedHotkeyHandler()
});
LoadComponent(modSelectOverlay = CreateModSelectOverlay());
configBackgroundBlur = config.GetBindable<bool>(OsuSetting.SongSelectBackgroundBlur);
configBackgroundBlur.BindValueChanged(e =>
{
if (!this.IsCurrentScreen())
return;
updateBackgroundDim();
});
showConvertedBeatmaps = config.GetBindable<bool>(OsuSetting.ShowConvertedBeatmaps);
}
// Colour scheme for mod overlay is left as default (green) to match mods button.
// Not sure about this, but we'll iterate based on feedback.
protected virtual ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay
{
ShowPresets = true,
};
private void requestRecommendedSelection(IEnumerable<GroupedBeatmap> groupedBeatmaps)
{
var recommendedBeatmap = difficultyRecommender?.GetRecommendedBeatmap(groupedBeatmaps.Select(gb => gb.Beatmap)) ?? groupedBeatmaps.First().Beatmap;
queueBeatmapSelection(groupedBeatmaps.First(bug => bug.Beatmap.Equals(recommendedBeatmap)));
}
/// <summary>
/// Called when a selection is made to progress away from the song select screen.
///
/// This is the default action which should be provided to <see cref="SelectAndRun"/>.
/// </summary>
protected abstract void OnStart();
public override IReadOnlyList<ScreenFooterButton> CreateFooterButtons() => new ScreenFooterButton[]
{
new FooterButtonMods(modSelectOverlay)
{
Hotkey = GlobalAction.ToggleModSelection,
Mods = Mods,
Ruleset = Ruleset,
RequestDeselectAllMods = () =>
{
if (modSelectOverlay.State.Value == Visibility.Visible)
modSelectOverlay.DeselectAll();
else
Mods.Value = Array.Empty<Mod>();
}
},
new FooterButtonRandom
{
NextRandom = () =>
{
if (!carousel.NextRandom())
errorSample?.Play();
},
PreviousRandom = () =>
{
if (!carousel.PreviousRandom())
errorSample?.Play();
}
},
new FooterButtonOptions
{
Hotkey = GlobalAction.ToggleBeatmapOptions,
}
};
protected override void LoadComplete()
{
base.LoadComplete();
modSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(modSelectOverlay);
inputManager = GetContainingInputManager()!;
FilterControl.CriteriaChanged += criteriaChanged;
modSelectOverlay.State.BindValueChanged(v =>
{
if (!this.IsCurrentScreen())
return;
if (ShowOsuLogo)
logo?.FadeTo(v.NewValue == Visibility.Visible ? 0f : 1f, 200, Easing.OutQuint);
});
}
protected override void Update()
{
base.Update();
detailsArea.Height = wedgesContainer.ChildSize.Y - titleWedge.LayoutSize.Y - 4;
float widescreenBonusWidth = Math.Max(0, DrawWidth / DrawHeight - 2f);
mainGridContainer.ColumnDimensions = new[]
{
new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 700 + widescreenBonusWidth * 100),
new Dimension(),
new Dimension(GridSizeMode.Relative, 0.5f, minSize: 500, maxSize: 700 + widescreenBonusWidth * 300),
};
if (this.IsCurrentScreen())
updateDebounce();
}
#region Selection debounce
private BeatmapInfo? debounceQueuedSelection;
private double debounceElapsedTime;
private void debounceQueueSelection(BeatmapInfo beatmap)
{
debounceQueuedSelection = beatmap;
debounceElapsedTime = 0;
}
private void updateDebounce()
{
if (debounceQueuedSelection == null) return;
double elapsed = Clock.ElapsedFrameTime;
// When a key is being held, assume the user is traversing the carousel using key repeat.
// We want to change panels less often in this state (basically making debounce longer than initial key repeat, at least).
double debounceInterval = inputManager.CurrentState.Keyboard.Keys.HasAnyButtonPressed ? SELECTION_DEBOUNCE * 2 : SELECTION_DEBOUNCE;
// avoid debounce running early if there's a single long frame.
if (!DebugUtils.IsNUnitRunning && Clock.FramesPerSecond > 0)
elapsed = Math.Min(1000 / Clock.FramesPerSecond, elapsed);
debounceElapsedTime += elapsed;
if (debounceElapsedTime >= debounceInterval)
performDebounceSelection();
}
private void performDebounceSelection()
{
if (debounceQueuedSelection == null) return;
try
{
if (Beatmap.Value.BeatmapInfo.Equals(debounceQueuedSelection))
return;
Beatmap.Value = beatmaps.GetWorkingBeatmap(debounceQueuedSelection);
}
finally
{
cancelDebounceSelection();
}
}
private void cancelDebounceSelection()
{
debounceQueuedSelection = null;
debounceElapsedTime = 0;
}
#endregion
#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)}");
// Only restart playback if a new track.
// This is important so that when exiting gameplay, the track is not restarted back to the preview point.
music.Play(isNewTrack);
}
lastTrack.SetTarget(track);
}
private bool isHandlingLooping;
private void beginLooping()
{
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
/// <summary>
/// Finalises selection on the given <see cref="BeatmapInfo"/> and runs the provided action if possible.
/// </summary>
/// <param name="beatmap">The beatmap which should be selected. If not provided, the current globally selected beatmap will be used.</param>
/// <param name="startAction">The action to perform if conditions are met to be able to proceed. May not be invoked if in an invalid state.</param>
protected void SelectAndRun(BeatmapInfo beatmap, Action startAction)
{
if (!this.IsCurrentScreen())
return;
if (!checkBeatmapValidForSelection(beatmap))
return;
// To ensure sanity, cancel any pending selection as we are about to force a selection.
// Carousel selection will update to the forced selection via a call of `ensureGlobalBeatmapValid` below, or when song select becomes current again.
cancelDebounceSelection();
// Forced refetch is important here to guarantee correct invalidation across all difficulties (editor specific).
Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap, true);
if (Beatmap.IsDefault)
return;
startAction();
}
/// <summary>
/// Prepares the proposed beatmap for global selection based on a carousel user-performed action.
/// </summary>
/// <remarks>
/// Calling this method will:
/// - Immediately update the selection the carousel.
/// - After <see cref="SELECTION_DEBOUNCE"/>, update the global beatmap. This in turn causes song select visuals (title, details, leaderboard) to update.
/// This debounce is intended to avoid high overheads from churning lookups while a user is changing selection via rapid keyboard operations.
/// </remarks>
/// <param name="groupedBeatmap">The beatmap to be selected.</param>
private void queueBeatmapSelection(GroupedBeatmap groupedBeatmap)
{
if (!this.IsCurrentScreen())
return;
carousel.CurrentGroupedBeatmap = groupedBeatmap;
// Debounce consideration is to avoid beatmap churn on key repeat selection.
debounceQueueSelection(groupedBeatmap.Beatmap);
}
private bool ensureGlobalBeatmapValid()
{
if (!this.IsCurrentScreen())
return false;
performDebounceSelection();
// While filtering, let's not ever attempt to change selection.
// This will be resolved after the filter completes, see `newItemsPresented`.
if (IsFiltering)
return false;
// Refetch to be confident that the current selection is still valid. It may have been deleted or hidden.
var currentBeatmap = beatmaps.GetWorkingBeatmap(Beatmap.Value.BeatmapInfo, true);
bool validSelection = checkBeatmapValidForSelection(currentBeatmap.BeatmapInfo);
if (validSelection)
{
carousel.CurrentBeatmap = currentBeatmap.BeatmapInfo;
return true;
}
// If there was no beatmap selected, pick a random one.
if (Beatmap.IsDefault)
{
validSelection = carousel.NextRandom();
performDebounceSelection();
return validSelection;
}
// If a previous non-default selection became non-valid, it was likely hidden or deleted.
if (!validSelection)
{
// In the case a difficulty was hidden or removed, prefer selecting another difficulty from the same set.
var activeSet = currentBeatmap.BeatmapSetInfo;
var validBeatmaps = activeSet.Beatmaps.Where(checkBeatmapValidForSelection).ToArray();
if (validBeatmaps.Any())
{
var beatmap = difficultyRecommender?.GetRecommendedBeatmap(validBeatmaps) ?? validBeatmaps.First();
carousel.CurrentBeatmap = beatmap;
debounceQueueSelection(beatmap);
return true;
}
}
// If all else fails, use the default beatmap.
Beatmap.SetDefault();
performDebounceSelection();
return validSelection;
}
private bool checkBeatmapValidForSelection(BeatmapInfo beatmap)
{
if (!beatmap.AllowGameplayWithRuleset(Ruleset.Value, showConvertedBeatmaps.Value))
return false;
if (beatmap.Hidden)
return false;
if (beatmap.BeatmapSet == null)
return false;
if (beatmap.BeatmapSet.Protected || beatmap.BeatmapSet.DeletePending)
return false;
return true;
}
#endregion
#region Transitions
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
this.FadeIn();
onArrivingAtScreen();
}
public override void OnResuming(ScreenTransitionEvent e)
{
base.OnResuming(e);
this.FadeIn(fade_duration, Easing.OutQuint);
onArrivingAtScreen();
ensureGlobalBeatmapValid();
detailsArea.Refresh();
if (ControlGlobalMusic)
{
// restart playback on returning to song select, regardless.
// not sure this should be a permanent thing (we may want to leave a user pause paused even on returning)
music.ResetTrackAdjustments();
music.Play(requestedByUser: true);
}
}
public override void OnSuspending(ScreenTransitionEvent e)
{
carousel.VisuallyFocusSelected = true;
this.FadeOut(fade_duration, Easing.OutQuint);
onLeavingScreen();
base.OnSuspending(e);
}
public override bool OnExiting(ScreenExitEvent e)
{
this.FadeOut(fade_duration, Easing.OutQuint);
onLeavingScreen();
return base.OnExiting(e);
}
private void onArrivingAtScreen()
{
modSelectOverlay.Beatmap.BindTo(Beatmap);
modSelectOverlay.Ruleset.BindTo(Ruleset);
// required due to https://github.com/ppy/osu-framework/issues/3218
modSelectOverlay.SelectedMods.Disabled = false;
modSelectOverlay.SelectedMods.BindTo(Mods);
carousel.VisuallyFocusSelected = false;
if (ControlGlobalMusic)
{
// Avoid abruptly starting playback at preview point.
// Importantly, this should be done before looping is setup to ensure we get the correct imminent `IsPlaying` state.
if (!music.IsPlaying)
{
music.DuckMomentarily(0, new DuckParameters
{
DuckDuration = 0,
DuckVolumeTo = 0,
RestoreDuration = 800,
RestoreEasing = Easing.OutQuint
});
}
beginLooping();
}
Beatmap.BindValueChanged(updateVariousState, true);
}
private void updateVariousState(ValueChangedEvent<WorkingBeatmap> e)
{
if (!this.IsCurrentScreen())
return;
ensureGlobalBeatmapValid();
ensurePlayingSelected();
updateBackgroundDim();
updateWedgeVisibility();
fetchOnlineInfo(force: ReferenceEquals(e.OldValue, e.NewValue));
}
private void onLeavingScreen()
{
restoreBackground();
Beatmap.ValueChanged -= updateVariousState;
modSelectOverlay.SelectedMods.UnbindFrom(Mods);
modSelectOverlay.Ruleset.UnbindFrom(Ruleset);
modSelectOverlay.Beatmap.UnbindFrom(Beatmap);
updateWedgeVisibility();
endLooping();
}
protected override void LogoArriving(OsuLogo logo, bool resuming)
{
base.LogoArriving(logo, resuming);
if (!ShowOsuLogo)
return;
if (logo.Alpha > 0.8f && resuming)
Footer?.StartTrackingLogo(logo, 400, Easing.OutQuint);
else
{
logo.Hide();
logo.ScaleTo(0.2f);
Footer?.StartTrackingLogo(logo);
}
logo.FadeIn(240, Easing.OutQuint);
logo.ScaleTo(logo_scale, 240, Easing.OutQuint);
logo.Action = () =>
{
ensureGlobalBeatmapValid();
SelectAndRun(Beatmap.Value.BeatmapInfo, OnStart);
return false;
};
}
protected override void LogoSuspending(OsuLogo logo)
{
base.LogoSuspending(logo);
if (!ShowOsuLogo)
return;
Footer?.StopTrackingLogo();
}
protected override void LogoExiting(OsuLogo logo)
{
base.LogoExiting(logo);
if (!ShowOsuLogo)
return;
Footer?.StopTrackingLogo();
logo.ScaleTo(0.2f, 120, Easing.Out);
logo.FadeOut(120, Easing.Out);
}
private void updateWedgeVisibility()
{
// Ensure we don't show an invalid selection before the carousel has finished initially filtering.
// This avoids a flicker of a placeholder or invalid beatmap before a proper selection.
//
// After the carousel finishes filtering, it will attempt a selection then call this method again.
if (!CarouselItemsPresented && !checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo))
return;
if (carousel.VisuallyFocusSelected)
{
titleWedge.Hide();
detailsArea.Hide();
FilterControl.Hide();
}
else
{
titleWedge.Show();
detailsArea.Show();
FilterControl.Show();
}
}
private void updateBackgroundDim() => ApplyToBackground(backgroundModeBeatmap =>
{
backgroundModeBeatmap.Beatmap = Beatmap.Value;
backgroundModeBeatmap.IgnoreUserSettings.Value = true;
backgroundModeBeatmap.DimWhenUserSettingsIgnored.Value = 0.1f;
// Required to undo results screen dimming the background.
// Probably needs more thought because this needs to be in every `ApplyToBackground` currently to restore sane defaults.
backgroundModeBeatmap.FadeColour(Color4.White, 250);
bool backgroundRevealActive = revealBackgroundDelegate?.State == ScheduledDelegate.RunState.Running || revealBackgroundDelegate?.State == ScheduledDelegate.RunState.Complete;
backgroundModeBeatmap.BlurAmount.Value = configBackgroundBlur.Value && !backgroundRevealActive ? 20 : 0f;
});
#endregion
#region Filtering
/// <summary>
/// Whether the carousel has finished initial presentation of beatmap panels.
/// </summary>
public bool CarouselItemsPresented { get; private set; }
/// <summary>
/// Whether the carousel is or will be undergoing a filter operation.
/// </summary>
public bool IsFiltering => carousel.IsFiltering || filterDebounce?.State == ScheduledDelegate.RunState.Waiting;
private const double filter_delay = 250;
private ScheduledDelegate? filterDebounce;
private void criteriaChanged(FilterCriteria criteria)
{
filterDebounce?.Cancel();
// The first filter needs to be applied immediately as this triggers the initial carousel load.
bool isFirstFilter = filterDebounce == null;
// Criteria change may have included a ruleset change which made the current selection invalid.
bool isSelectionValid = checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo);
filterDebounce = Scheduler.AddDelayed(() => carousel.Filter(criteria, !isSelectionValid), isFirstFilter || !isSelectionValid ? 0 : filter_delay);
}
private void newItemsPresented(IEnumerable<CarouselItem> carouselItems)
{
if (carousel.Criteria == null)
return;
CarouselItemsPresented = true;
int count = carousel.MatchedBeatmapsCount;
updateNoResultsPlaceholder();
// 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 there's already a selection update in progress, let's not interrupt it.
// Interrupting could cause the debounce interval to be reduced.
//
// `ensureGlobalBeatmapValid` is run post-selection which will resolve any pending incompatibilities (see `Beatmap` bindable callback).
if (debounceQueuedSelection == null)
ensureGlobalBeatmapValid();
updateWedgeVisibility();
}
private void updateNoResultsPlaceholder()
{
int count = carousel.MatchedBeatmapsCount;
if (count == 0)
{
if (noResultsPlaceholder.State.Value == Visibility.Hidden)
{
// Duck audio temporarily when the no results placeholder becomes visible.
//
// Temporary ducking makes it easier to avoid scenarios where the ducking interacts badly
// with other global UI components (like overlays).
music.DuckMomentarily(400, new DuckParameters
{
DuckVolumeTo = 1,
DuckCutoffTo = 500,
DuckDuration = 250,
RestoreDuration = 2000,
});
}
noResultsPlaceholder.Show();
noResultsPlaceholder.Filter = carousel.Criteria!;
rightGradientBackground.ResizeWidthTo(3, 1000, Easing.OutPow10);
}
else
{
noResultsPlaceholder.Hide();
rightGradientBackground.ResizeWidthTo(1, 400, Easing.OutPow10);
}
}
#endregion
#region Background reveal
private ScheduledDelegate? revealBackgroundDelegate;
public CursorContainer? Cursor => null;
bool IProvideCursor.ProvidingUserCursor => revealBackgroundDelegate?.Completed == true;
protected override bool OnHover(HoverEvent e) => true;
protected override bool OnMouseDown(MouseDownEvent e)
{
var containingInputManager = GetContainingInputManager();
// I don't know why this works, but it does.
// If the carousel panels are hovered, hovered no longer contains the screen.
// Maybe there's a better way of doing this, but I couldn't immediately find a good setup.
bool mouseDownPriority = containingInputManager!.HoveredDrawables.Contains(this);
// Touch input synthesises right clicks, which allow absolute scroll of the carousel.
// For simplicity, disable this functionality on mobile.
bool isTouchInput = e.CurrentState.Mouse.LastSource is ISourcedFromTouch;
if (!carousel.AbsoluteScrolling && !isTouchInput && mouseDownPriority && revealBackgroundDelegate == null)
{
revealBackgroundDelegate = Scheduler.AddDelayed(() =>
{
if (containingInputManager.DraggedDrawable != null)
{
revealBackgroundDelegate = null;
return;
}
mainContent.ResizeWidthTo(1.2f, 600, Easing.OutQuint);
mainContent.ScaleTo(1.2f, 600, Easing.OutQuint);
mainContent.FadeOut(200, Easing.OutQuint);
skinnableContent.ResizeWidthTo(1.2f, 600, Easing.OutQuint);
skinnableContent.ScaleTo(1.2f, 600, Easing.OutQuint);
skinnableContent.FadeOut(200, Easing.OutQuint);
updateBackgroundDim();
Footer?.Hide();
}, 200);
}
return base.OnMouseDown(e);
}
protected override void OnMouseUp(MouseUpEvent e)
{
restoreBackground();
base.OnMouseUp(e);
}
private void restoreBackground()
{
if (revealBackgroundDelegate == null)
return;
if (revealBackgroundDelegate.State == ScheduledDelegate.RunState.Complete)
{
mainContent.ResizeWidthTo(1f, 500, Easing.OutQuint);
mainContent.ScaleTo(1, 500, Easing.OutQuint);
mainContent.FadeIn(500, Easing.OutQuint);
skinnableContent.ResizeWidthTo(1f, 500, Easing.OutQuint);
skinnableContent.ScaleTo(1, 500, Easing.OutQuint);
skinnableContent.FadeIn(500, Easing.OutQuint);
Footer?.Show();
}
revealBackgroundDelegate.Cancel();
revealBackgroundDelegate = null;
updateBackgroundDim();
}
#endregion
#region Input
public virtual bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
if (!this.IsCurrentScreen()) return false;
if (game == null)
return false;
var flattenedMods = ModUtils.FlattenMods(game.AvailableMods.Value.SelectMany(kv => kv.Value));
switch (e.Action)
{
case GlobalAction.Select:
// in most circumstances this is handled already by the carousel itself, but there are cases where it will not be.
// one of which is filtering out all visible beatmaps and attempting to start gameplay.
// in that case, users still expect a `Select` press to advance to gameplay anyway, using the ambient selected beatmap if there is one,
// which matches the behaviour resulting from clicking the osu! cookie in that scenario.
ensureGlobalBeatmapValid();
SelectAndRun(Beatmap.Value.BeatmapInfo, OnStart);
return true;
case GlobalAction.IncreaseModSpeed:
return modSpeedHotkeyHandler.ChangeSpeed(0.05, flattenedMods);
case GlobalAction.DecreaseModSpeed:
return modSpeedHotkeyHandler.ChangeSpeed(-0.05, flattenedMods);
}
return false;
}
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.Repeat) return false;
switch (e.Key)
{
case Key.Delete:
if (e.ShiftPressed)
{
if (!Beatmap.IsDefault)
Delete(Beatmap.Value.BeatmapSetInfo);
return true;
}
break;
}
return base.OnKeyDown(e);
}
#endregion
#region Online lookups
public enum BeatmapSetLookupStatus
{
InProgress,
Completed,
}
public class BeatmapSetLookupResult
{
public BeatmapSetLookupStatus Status { get; }
public APIBeatmapSet? Result { get; }
private BeatmapSetLookupResult(BeatmapSetLookupStatus status, APIBeatmapSet? result)
{
Status = status;
Result = result;
}
public static BeatmapSetLookupResult InProgress() => new BeatmapSetLookupResult(BeatmapSetLookupStatus.InProgress, null);
public static BeatmapSetLookupResult Completed(APIBeatmapSet? beatmapSet) => new BeatmapSetLookupResult(BeatmapSetLookupStatus.Completed, beatmapSet);
}
/// <summary>
/// Result of the latest online beatmap set lookup.
/// Note that this being <see langword="null"/> or <see cref="BeatmapSetLookupResult.InProgress"/> is different from
/// being a <see cref="BeatmapSetLookupResult.Completed"/> with a <see cref="BeatmapSetLookupResult.Result"/> of null.
/// The former indicates a lookup never occurring or being in progress, while the latter indicates a completed lookup with no result.
/// </summary>
[Cached(typeof(IBindable<BeatmapSetLookupResult?>))]
private readonly Bindable<BeatmapSetLookupResult?> lastLookupResult = new Bindable<BeatmapSetLookupResult?>();
private CancellationTokenSource? onlineLookupCancellation;
private Task<APIBeatmapSet?>? currentOnlineLookup;
private void fetchOnlineInfo(bool force = false)
{
var beatmapSetInfo = Beatmap.Value.BeatmapSetInfo;
if (lastLookupResult.Value?.Result?.OnlineID == beatmapSetInfo.OnlineID && !force)
return;
onlineLookupCancellation?.Cancel();
onlineLookupCancellation = null;
if (beatmapSetInfo.OnlineID < 0)
{
lastLookupResult.Value = BeatmapSetLookupResult.Completed(null);
return;
}
lastLookupResult.Value = BeatmapSetLookupResult.InProgress();
onlineLookupCancellation = new CancellationTokenSource();
currentOnlineLookup = onlineLookupSource.GetBeatmapSetAsync(beatmapSetInfo.OnlineID, onlineLookupCancellation.Token);
currentOnlineLookup.ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
Schedule(() => lastLookupResult.Value = BeatmapSetLookupResult.Completed(t.GetResultSafely()));
if (t.Exception != null)
{
Logger.Log($"Error when fetching online beatmap set: {t.Exception}", LoggingTarget.Network);
Schedule(() => lastLookupResult.Value = BeatmapSetLookupResult.Completed(null));
}
});
}
#endregion
#region Implementation of ISongSelect
void ISongSelect.Search(string query) => FilterControl.Search(query);
bool ISongSelect.CanPresentScore => true;
void ISongSelect.PresentScore(ScoreInfo score, ScorePresentType presentType)
{
switch (presentType)
{
case ScorePresentType.Results:
Debug.Assert(Beatmap.Value.BeatmapInfo.Equals(score.BeatmapInfo));
Debug.Assert(Ruleset.Value.Equals(score.Ruleset));
this.Push(new SoloResultsScreen(score));
break;
case ScorePresentType.Gameplay:
(game as OsuGame)?.PresentScore(score, presentType);
break;
}
}
#endregion
#region IHandlePresentBeatmap
void IHandlePresentBeatmap.PresentBeatmap(WorkingBeatmap workingBeatmap, RulesetInfo ruleset)
{
cancelDebounceSelection();
var beatmapInfo = workingBeatmap.BeatmapInfo;
// Don't change the local ruleset if the user is on another ruleset and is showing converted beatmaps.
// Eventually we probably want to check whether conversion is actually possible for the current ruleset.
bool requiresRulesetSwitch = !beatmapInfo.Ruleset.Equals(Ruleset.Value)
&& (beatmapInfo.Ruleset.OnlineID > 0 || !showConvertedBeatmaps.Value);
if (requiresRulesetSwitch)
{
Ruleset.Value = beatmapInfo.Ruleset;
Beatmap.Value = workingBeatmap;
Logger.Log($"Completing {nameof(IHandlePresentBeatmap.PresentBeatmap)} with beatmap {workingBeatmap} ruleset {beatmapInfo.Ruleset}");
}
else
{
Beatmap.Value = workingBeatmap;
Logger.Log($"Completing {nameof(IHandlePresentBeatmap.PresentBeatmap)} with beatmap {workingBeatmap} (maintaining ruleset)");
}
}
#endregion
#region Beatmap management
[Resolved]
private ManageCollectionsDialog? manageCollectionsDialog { get; set; }
[Resolved]
private RealmAccess realm { get; set; } = null!;
public virtual IEnumerable<OsuMenuItem> GetForwardActions(BeatmapInfo beatmap)
{
yield return new OsuMenuItem(GlobalActionKeyBindingStrings.Select, MenuItemType.Highlighted, () => SelectAndRun(beatmap, OnStart))
{
Icon = FontAwesome.Solid.Check
};
yield return new OsuMenuItemSpacer();
if (beatmap.OnlineID > 0)
{
yield return new OsuMenuItem(CommonStrings.Details, MenuItemType.Standard, () => beatmapOverlay?.FetchAndShowBeatmap(beatmap.OnlineID));
if (beatmap.GetOnlineURL(api, Ruleset.Value) is string url)
yield return new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => (game as OsuGame)?.CopyToClipboard(url));
}
yield return new OsuMenuItemSpacer();
foreach (var i in CreateCollectionMenuActions(beatmap))
yield return i;
}
protected IEnumerable<OsuMenuItem> CreateCollectionMenuActions(BeatmapInfo beatmap)
{
var collectionItems = realm.Realm.All<BeatmapCollection>()
.OrderBy(c => c.Name)
.AsEnumerable()
.Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmap)).Cast<OsuMenuItem>().ToList();
collectionItems.Add(new OsuMenuItem(CommonStrings.Manage, MenuItemType.Standard, () => manageCollectionsDialog?.Show()));
yield return new OsuMenuItem(CommonStrings.Collections) { Items = collectionItems };
}
public void ManageCollections() => collectionsDialog?.Show();
public void Delete(BeatmapSetInfo beatmapSet) => dialogOverlay?.Push(new BeatmapDeleteDialog(beatmapSet));
public void RestoreAllHidden(BeatmapSetInfo beatmapSet)
{
foreach (var b in beatmapSet.Beatmaps)
beatmaps.Restore(b);
}
private GroupedBeatmap? beforeScopedSelection;
private readonly Bindable<BeatmapSetInfo?> scopedBeatmapSet = new Bindable<BeatmapSetInfo?>();
public IBindable<BeatmapSetInfo?> ScopedBeatmapSet => scopedBeatmapSet;
public void ScopeToBeatmapSet(BeatmapSetInfo beatmapSet)
{
beforeScopedSelection = carousel.CurrentGroupedBeatmap;
scopedBeatmapSet.Value = beatmapSet;
}
public void UnscopeBeatmapSet()
{
if (scopedBeatmapSet.Value == null)
return;
if (beforeScopedSelection != null)
queueBeatmapSelection(beforeScopedSelection);
scopedBeatmapSet.Value = null;
beforeScopedSelection = null;
}
#endregion
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
modSelectOverlayRegistration?.Dispose();
}
}
}