Files
Ez2Lazer/osu.Game/OsuGame.cs
Dean Herbert 12aab50e5c Move v2 files to final location
This contains only renames and namespace updates.
2026-03-04 21:28:14 +08:00

1885 lines
77 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Humanizer;
using JetBrains.Annotations;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Configuration;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Input.Handlers.Tablet;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Framework.Platform;
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.Extensions;
using osu.Game.LAsEzExtensions.Analysis;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osu.Game.IO;
using osu.Game.Localisation;
using osu.Game.Online;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Chat;
using osu.Game.Online.Leaderboards;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapListing;
using osu.Game.Overlays.Mods;
using osu.Game.Overlays.Music;
using osu.Game.Overlays.Notifications;
using osu.Game.Overlays.OSD;
using osu.Game.Overlays.SkinEditor;
using osu.Game.Overlays.Toolbar;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Screens;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Footer;
using osu.Game.Screens.Menu;
using osu.Game.Screens.OnlinePlay.DailyChallenge;
using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.Leaderboards;
using osu.Game.Screens.Ranking;
using osu.Game.Screens.Select;
using osu.Game.Seasonal;
using osu.Game.Skinning;
using osu.Game.Updater;
using osu.Game.Users;
using osu.Game.Utils;
using osuTK;
using osuTK.Graphics;
using Sentry;
using IntroScreen = osu.Game.Screens.Menu.IntroScreen;
using MatchType = osu.Game.Online.Rooms.MatchType;
namespace osu.Game
{
/// <summary>
/// The full osu! experience. Builds on top of <see cref="OsuGameBase"/> to add menus and binding logic
/// for initial components that are generally retrieved via DI.
/// </summary>
[Cached(typeof(OsuGame))]
public partial class OsuGame : OsuGameBase, IKeyBindingHandler<GlobalAction>, ILocalUserPlayInfo, IPerformFromScreenRunner, IOverlayManager, ILinkHandler
{
#if DEBUG
// Different port allows running release and debug builds alongside each other.
public const string IPC_PIPE_NAME = "osu-lazer-debug";
#else
public const string IPC_PIPE_NAME = "osu-lazer";
#endif
/// <summary>
/// The amount of global offset to apply when a left/right anchored overlay is displayed (ie. settings or notifications).
/// </summary>
protected const float SIDE_OVERLAY_OFFSET_RATIO = 0.05f;
/// <summary>
/// A common shear factor applied to most components of the game.
/// </summary>
public static readonly Vector2 SHEAR = new Vector2(0.2f, 0);
/// <summary>
/// For elements placed close to the screen edge, this is the margin to leave to the edge.
/// </summary>
public const float SCREEN_EDGE_MARGIN = 12f;
private const double general_log_debounce = 60000;
private const string tablet_log_prefix = @"[Tablet] ";
public Toolbar Toolbar { get; private set; }
private ChatOverlay chatOverlay;
private ChannelManager channelManager;
[NotNull]
protected readonly NotificationOverlay Notifications = new NotificationOverlay();
private BeatmapListingOverlay beatmapListing;
private DashboardOverlay dashboard;
private NewsOverlay news;
private UserProfileOverlay userProfile;
private BeatmapSetOverlay beatmapSetOverlay;
private WikiOverlay wikiOverlay;
private ChangelogOverlay changelogOverlay;
private SkinEditorOverlay skinEditor;
private Container overlayContent;
private Container rightFloatingOverlayContent;
private Container leftFloatingOverlayContent;
private Container topMostOverlayContent;
private Container footerBasedOverlayContent;
protected ScalingContainer ScreenContainer { get; private set; }
protected Container ScreenOffsetContainer { get; private set; }
private Container overlayOffsetContainer;
private OnScreenDisplay onScreenDisplay;
[Resolved]
private FrameworkConfigManager frameworkConfig { get; set; }
private const int non_gameplay_draw_multiplier = 4;
private const int maximum_sane_draw_fps = 8000;
private Bindable<FrameSync> frameSyncMode;
private GameHost gameHost;
private bool gameplayScreenActive;
private DifficultyRecommender difficultyRecommender;
[Cached]
private readonly LegacyImportManager legacyImportManager = new LegacyImportManager();
[Cached]
private readonly ScreenshotManager screenshotManager = new ScreenshotManager();
private SentryLogger sentryLogger;
public virtual StableStorage GetStorageForStableInstall() => null;
private float toolbarOffset => (Toolbar?.Position.Y ?? 0) + (Toolbar?.DrawHeight ?? 0);
private IdleTracker idleTracker;
/// <summary>
/// Whether the user is currently in an idle state.
/// </summary>
public IBindable<bool> IsIdle => idleTracker.IsIdle;
/// <summary>
/// Whether overlays should be able to be opened game-wide. Value is sourced from the current active screen.
/// </summary>
public readonly IBindable<OverlayActivation> OverlayActivationMode = new Bindable<OverlayActivation>();
IBindable<LocalUserPlayingState> ILocalUserPlayInfo.PlayingState => UserPlayingState;
protected readonly Bindable<LocalUserPlayingState> UserPlayingState = new Bindable<LocalUserPlayingState>();
protected OsuScreenStack ScreenStack;
protected BackButton BackButton => screenStackFooter.BackButton;
protected ScreenFooter ScreenFooter => screenStackFooter.Footer;
protected SettingsOverlay Settings;
protected FirstRunSetupOverlay FirstRunOverlay { get; private set; }
private FPSCounter fpsCounter;
private VolumeOverlay volume;
private OsuLogo osuLogo;
private MainMenu menuScreen;
[CanBeNull]
private DevBuildBanner devBuildBanner;
[CanBeNull]
private IntroScreen introScreen;
private Bindable<string> configRuleset;
private Bindable<bool> applySafeAreaConsiderations;
private Bindable<float> uiScale;
private Bindable<UserActivity> configUserActivity;
private Bindable<string> configSkin;
private RealmDetachedBeatmapStore detachedBeatmapStore;
private ScreenStackFooter screenStackFooter;
private readonly string[] args;
private readonly List<OsuFocusedOverlayContainer> focusedOverlays = new List<OsuFocusedOverlayContainer>();
private readonly List<OverlayContainer> externalOverlays = new List<OverlayContainer>();
private readonly List<OverlayContainer> visibleBlockingOverlays = new List<OverlayContainer>();
/// <summary>
/// Whether the game should be limited to only display officially licensed content.
/// </summary>
public virtual bool HideUnlicensedContent => false;
private bool tabletLogNotifyOnWarning = true;
private bool tabletLogNotifyOnError = true;
private int generalLogRecentCount;
public OsuGame(string[] args = null)
{
this.args = args;
Logger.NewEntry += forwardGeneralLogToNotifications;
Logger.NewEntry += forwardTabletLogToNotifications;
Schedule(() =>
{
ITabletHandler tablet = Host.AvailableInputHandlers.OfType<ITabletHandler>().SingleOrDefault();
tablet?.Tablet.BindValueChanged(_ =>
{
tabletLogNotifyOnWarning = true;
tabletLogNotifyOnError = true;
}, true);
});
}
#region IOverlayManager
IBindable<OverlayActivation> IOverlayManager.OverlayActivationMode => OverlayActivationMode;
private void updateBlockingOverlayFade() =>
ScreenContainer.FadeColour(visibleBlockingOverlays.Any() ? OsuColour.Gray(0.5f) : Color4.White, 500, Easing.OutQuint);
IDisposable IOverlayManager.RegisterBlockingOverlay(OverlayContainer overlayContainer)
{
if (overlayContainer.Parent != null)
throw new ArgumentException($@"Overlays registered via {nameof(IOverlayManager.RegisterBlockingOverlay)} should not be added to the scene graph.");
if (externalOverlays.Contains(overlayContainer))
throw new ArgumentException($@"{overlayContainer} has already been registered via {nameof(IOverlayManager.RegisterBlockingOverlay)} once.");
externalOverlays.Add(overlayContainer);
if (overlayContainer is ShearedOverlayContainer)
footerBasedOverlayContent.Add(overlayContainer);
else
overlayContent.Add(overlayContainer);
if (overlayContainer is OsuFocusedOverlayContainer focusedOverlayContainer)
focusedOverlays.Add(focusedOverlayContainer);
return new InvokeOnDisposal(() => unregisterBlockingOverlay(overlayContainer));
}
void IOverlayManager.ShowBlockingOverlay(OverlayContainer overlay)
{
if (!visibleBlockingOverlays.Contains(overlay))
visibleBlockingOverlays.Add(overlay);
updateBlockingOverlayFade();
}
void IOverlayManager.HideBlockingOverlay(OverlayContainer overlay) => Schedule(() =>
{
visibleBlockingOverlays.Remove(overlay);
updateBlockingOverlayFade();
});
/// <summary>
/// Unregisters a blocking <see cref="OverlayContainer"/> that was not created by <see cref="OsuGame"/> itself.
/// </summary>
private void unregisterBlockingOverlay(OverlayContainer overlayContainer) => Schedule(() =>
{
externalOverlays.Remove(overlayContainer);
if (overlayContainer is OsuFocusedOverlayContainer focusedOverlayContainer)
focusedOverlays.Remove(focusedOverlayContainer);
overlayContainer.Expire();
});
#endregion
/// <summary>
/// Close all game-wide overlays.
/// </summary>
/// <param name="hideToolbar">Whether the toolbar should also be hidden.</param>
public void CloseAllOverlays(bool hideToolbar = true)
{
foreach (var overlay in focusedOverlays)
overlay.Hide();
ScreenFooter.ActiveOverlay?.Hide();
if (hideToolbar) Toolbar.Hide();
}
protected override UserInputManager CreateUserInputManager()
{
var userInputManager = base.CreateUserInputManager();
(userInputManager as OsuUserInputManager)?.PlayingState.BindTo(UserPlayingState);
return userInputManager;
}
private DependencyContainer dependencies;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
private readonly List<string> dragDropFiles = new List<string>();
private ScheduledDelegate dragDropImportSchedule;
public override void SetupLogging(Storage gameStorage, Storage cacheStorage)
{
base.SetupLogging(gameStorage, cacheStorage);
sentryLogger = new SentryLogger(this, cacheStorage);
}
public override void SetHost(GameHost host)
{
base.SetHost(host);
gameHost = host;
if (host.Window != null)
{
host.Window.CursorState |= CursorState.Hidden;
host.Window.DragDrop += onWindowDragDrop;
}
}
private void onWindowDragDrop(string path)
{
// on macOS/iOS, URL associations are handled via SDL_DROPFILE events.
if (path.StartsWith(OSU_PROTOCOL, StringComparison.Ordinal))
{
HandleLink(path);
return;
}
lock (dragDropFiles)
{
dragDropFiles.Add(path);
Logger.Log($@"Adding ""{Path.GetFileName(path)}"" for import");
// File drag drop operations can potentially trigger hundreds or thousands of these calls on some platforms.
// In order to avoid spawning multiple import tasks for a single drop operation, debounce a touch.
dragDropImportSchedule?.Cancel();
dragDropImportSchedule = Scheduler.AddDelayed(handlePendingDragDropImports, 100);
}
void handlePendingDragDropImports()
{
lock (dragDropFiles)
{
Logger.Log($"Handling batch import of {dragDropFiles.Count} files");
string[] paths = dragDropFiles.ToArray();
dragDropFiles.Clear();
Task.Factory.StartNew(() => Import(paths), TaskCreationOptions.LongRunning);
}
}
}
[BackgroundDependencyLoader]
private void load()
{
sentryLogger.AttachUser(API.LocalUser);
if (SeasonalUIConfig.ENABLED)
dependencies.CacheAs(osuLogo = new OsuLogoChristmas { Alpha = 0 });
else
dependencies.CacheAs(osuLogo = new OsuLogo { Alpha = 0 });
// bind config int to database RulesetInfo
configRuleset = LocalConfig.GetBindable<string>(OsuSetting.Ruleset);
uiScale = LocalConfig.GetBindable<float>(OsuSetting.UIScale);
var preferredRuleset = RulesetStore.GetRuleset(configRuleset.Value);
try
{
Ruleset.Value = preferredRuleset ?? RulesetStore.AvailableRulesets.First();
}
catch (Exception e)
{
// on startup, a ruleset may be selected which has compatibility issues.
Logger.Error(e, $@"Failed to switch to preferred ruleset {preferredRuleset}.");
Ruleset.Value = RulesetStore.AvailableRulesets.First();
}
Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ShortName;
configUserActivity = SessionStatics.GetBindable<UserActivity>(Static.UserOnlineActivity);
configSkin = LocalConfig.GetBindable<string>(OsuSetting.Skin);
// Transfer skin from config to realm instance once on startup.
SkinManager.SetSkinFromConfiguration(configSkin.Value);
// Transfer any runtime changes back to configuration file.
SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString();
UserPlayingState.BindValueChanged(p =>
{
BeatmapManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying;
SkinManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying;
ScoreManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying;
}, true);
IsActive.BindValueChanged(active => updateActiveState(active.NewValue), true);
Audio.AddAdjustment(AdjustableProperty.Volume, inactiveVolumeFade);
SelectedMods.BindValueChanged(modsChanged);
Beatmap.BindValueChanged(beatmapChanged, true);
configUserActivity.BindValueChanged(_ => updateWindowTitle());
applySafeAreaConsiderations = LocalConfig.GetBindable<bool>(OsuSetting.SafeAreaConsiderations);
applySafeAreaConsiderations.BindValueChanged(apply => SafeAreaContainer.SafeAreaOverrideEdges = apply.NewValue ? SafeAreaOverrideEdges : Edges.All, true);
}
private ExternalLinkOpener externalLinkOpener;
/// <summary>
/// Handle an arbitrary URL. Displays via in-game overlays where possible.
/// This can be called from a non-thread-safe non-game-loaded state.
/// </summary>
/// <param name="url">The URL to load.</param>
public void HandleLink(string url) => HandleLink(MessageFormatter.GetLinkDetails(url));
/// <summary>
/// Handle a specific <see cref="LinkDetails"/>.
/// This can be called from a non-thread-safe non-game-loaded state.
/// </summary>
/// <param name="link">The link to load.</param>
public void HandleLink(LinkDetails link) => Schedule(() =>
{
string argString = link.Argument.ToString() ?? string.Empty;
switch (link.Action)
{
case LinkAction.OpenBeatmap:
// TODO: proper query params handling
if (int.TryParse(argString.Contains('?') ? argString.Split('?')[0] : argString, out int beatmapId))
ShowBeatmap(beatmapId);
break;
case LinkAction.OpenBeatmapSet:
if (int.TryParse(argString, out int setId))
ShowBeatmapSet(setId);
break;
case LinkAction.OpenChannel:
ShowChannel(argString);
break;
case LinkAction.SearchBeatmapSet:
if (link.Argument is LocalisableString localisable)
SearchBeatmapSet(Localisation.GetLocalisedString(localisable));
else
SearchBeatmapSet(argString);
break;
case LinkAction.FilterBeatmapSetGenre:
FilterBeatmapSetGenre((SearchGenre)link.Argument);
break;
case LinkAction.FilterBeatmapSetLanguage:
FilterBeatmapSetLanguage((SearchLanguage)link.Argument);
break;
case LinkAction.OpenEditorTimestamp:
HandleTimestamp(argString);
break;
case LinkAction.Spectate:
waitForReady(() => Notifications, _ => Notifications.Post(new SimpleNotification
{
Text = NotificationsStrings.LinkTypeNotSupported,
Icon = FontAwesome.Solid.LifeRing,
}));
break;
case LinkAction.External:
OpenUrlExternally(argString);
break;
case LinkAction.OpenUserProfile:
ShowUser((IUser)link.Argument);
break;
case LinkAction.OpenWiki:
ShowWiki(argString);
break;
case LinkAction.OpenChangelog:
if (string.IsNullOrEmpty(argString))
ShowChangelogListing();
else
{
string[] changelogArgs = argString.Split("/");
ShowChangelogBuild($"{changelogArgs[1]}-{changelogArgs[0]}");
}
break;
case LinkAction.JoinRoom:
if (long.TryParse(argString, out long roomId))
JoinRoom(roomId);
break;
default:
throw new NotImplementedException($"This {nameof(LinkAction)} ({link.Action.ToString()}) is missing an associated action.");
}
});
public void CopyToClipboard(string value) => waitForReady(() => onScreenDisplay, _ =>
{
dependencies.Get<Clipboard>().SetText(value);
onScreenDisplay.Display(new CopiedToClipboardToast());
});
public void OpenUrlExternally(string url, LinkWarnMode warnMode = LinkWarnMode.Default) => waitForReady(() => externalLinkOpener, _ => externalLinkOpener.OpenUrlExternally(url, warnMode));
/// <summary>
/// Open a specific channel in chat.
/// </summary>
/// <param name="channel">The channel to display.</param>
public void ShowChannel(string channel) => waitForReady(() => channelManager, _ =>
{
try
{
channelManager.OpenChannel(channel);
}
catch (ChannelNotFoundException)
{
Logger.Log($"The requested channel \"{channel}\" does not exist");
}
});
/// <summary>
/// Show a beatmap set as an overlay.
/// </summary>
/// <param name="setId">The set to display.</param>
public void ShowBeatmapSet(int setId) => waitForReady(() => beatmapSetOverlay, _ => beatmapSetOverlay.FetchAndShowBeatmapSet(setId));
/// <summary>
/// Show a user's profile as an overlay.
/// </summary>
/// <param name="user">The user to display.</param>
public void ShowUser(IUser user) => waitForReady(() => userProfile, _ => userProfile.ShowUser(user));
/// <summary>
/// Show a beatmap's set as an overlay, displaying the given beatmap.
/// </summary>
/// <param name="beatmapId">The beatmap to show.</param>
public void ShowBeatmap(int beatmapId) => waitForReady(() => beatmapSetOverlay, _ => beatmapSetOverlay.FetchAndShowBeatmap(beatmapId));
/// <summary>
/// Shows the beatmap listing overlay, with the given <paramref name="query"/> in the search box.
/// </summary>
/// <param name="query">The query to search for.</param>
public void SearchBeatmapSet(string query) => waitForReady(() => beatmapListing, _ => beatmapListing.ShowWithSearch(query));
public void FilterBeatmapSetGenre(SearchGenre genre) => waitForReady(() => beatmapListing, _ => beatmapListing.ShowWithGenreFilter(genre));
public void FilterBeatmapSetLanguage(SearchLanguage language) => waitForReady(() => beatmapListing, _ => beatmapListing.ShowWithLanguageFilter(language));
/// <summary>
/// Show a wiki's page as an overlay
/// </summary>
/// <param name="path">The wiki page to show</param>
public void ShowWiki(string path) => waitForReady(() => wikiOverlay, _ => wikiOverlay.ShowPage(path));
/// <summary>
/// Show changelog listing overlay
/// </summary>
public void ShowChangelogListing() => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowListing());
/// <summary>
/// Show changelog's build as an overlay
/// </summary>
/// <param name="version">The build version, including stream suffix.</param>
public void ShowChangelogBuild(string version) => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowBuild(version));
/// <summary>
/// Joins a multiplayer or playlists room with the given <paramref name="id"/>.
/// </summary>
public void JoinRoom(long id)
{
var request = new GetRoomRequest(id);
request.Success += room =>
{
switch (room.Type)
{
case MatchType.Playlists:
PresentPlaylist(room);
break;
default:
PresentMultiplayerMatch(room, string.Empty);
break;
}
};
API.Queue(request);
}
/// <summary>
/// Seeks to the provided <paramref name="timestamp"/> if the editor is currently open.
/// Can also select objects as indicated by the <paramref name="timestamp"/> (depends on ruleset implementation).
/// </summary>
public void HandleTimestamp(string timestamp)
{
if (ScreenStack.CurrentScreen is not Editor editor)
{
Schedule(() => Notifications.Post(new SimpleErrorNotification
{
Icon = FontAwesome.Solid.ExclamationTriangle,
Text = EditorStrings.MustBeInEditorToHandleLinks
}));
return;
}
editor.HandleTimestamp(timestamp, notifyOnError: true);
}
/// <summary>
/// Present a skin select immediately.
/// </summary>
/// <param name="skin">The skin to select.</param>
public void PresentSkin(SkinInfo skin)
{
var databasedSkin = SkinManager.Query(s => s.ID == skin.ID);
if (databasedSkin == null)
{
Logger.Log("The requested skin could not be loaded.", LoggingTarget.Information);
return;
}
SkinManager.CurrentSkinInfo.Value = databasedSkin;
}
/// <summary>
/// Present a beatmap at song select immediately.
/// The user should have already requested this interactively.
/// </summary>
/// <param name="beatmap">The beatmap to select.</param>
/// <param name="difficultyCriteria">Optional predicate used to narrow the set of difficulties to select from when presenting.</param>
/// <remarks>
/// Among items satisfying the predicate, the order of preference is:
/// <list type="bullet">
/// <item>beatmap with recommended difficulty, as provided by <see cref="DifficultyRecommender"/>,</item>
/// <item>first beatmap from the current ruleset,</item>
/// <item>first beatmap from any ruleset.</item>
/// </list>
/// </remarks>
public void PresentBeatmap(IBeatmapSetInfo beatmap, Predicate<BeatmapInfo> difficultyCriteria = null)
{
Logger.Log($"Beginning {nameof(PresentBeatmap)} with beatmap {beatmap}");
Live<BeatmapSetInfo> databasedSet = null;
if (beatmap.OnlineID > 0)
databasedSet = BeatmapManager.QueryBeatmapSet(s => s.OnlineID == beatmap.OnlineID && !s.DeletePending);
if (beatmap is BeatmapSetInfo localBeatmap)
databasedSet ??= BeatmapManager.QueryBeatmapSet(s => s.Hash == localBeatmap.Hash && !s.DeletePending);
if (databasedSet == null)
{
Logger.Log("The requested beatmap could not be loaded.", LoggingTarget.Information);
return;
}
var detachedSet = databasedSet.PerformRead(s => s.Detach());
if (detachedSet.DeletePending)
{
Logger.Log("The requested beatmap has since been deleted.", LoggingTarget.Information);
return;
}
PerformFromScreen(screen =>
{
// Find beatmaps that match our predicate.
var beatmaps = detachedSet.Beatmaps.Where(b => difficultyCriteria?.Invoke(b) ?? true).ToList();
// Use all beatmaps if predicate matched nothing
if (beatmaps.Count == 0)
beatmaps = detachedSet.Beatmaps.ToList();
// Prefer recommended beatmap if recommendations are available, else fallback to a sane selection.
var selection = difficultyRecommender.GetRecommendedBeatmap(beatmaps)
?? beatmaps.FirstOrDefault(b => b.Ruleset.Equals(Ruleset.Value))
?? beatmaps.First();
if (screen is IHandlePresentBeatmap presentableScreen)
{
presentableScreen.PresentBeatmap(BeatmapManager.GetWorkingBeatmap(selection), selection.Ruleset);
}
else
{
// Don't change the local ruleset if the user is on another ruleset and is showing converted beatmaps at song select.
// Eventually we probably want to check whether conversion is actually possible for the current ruleset.
bool requiresRulesetSwitch = !selection.Ruleset.Equals(Ruleset.Value)
&& (selection.Ruleset.OnlineID > 0 || !LocalConfig.Get<bool>(OsuSetting.ShowConvertedBeatmaps));
if (requiresRulesetSwitch)
{
Ruleset.Value = selection.Ruleset;
Beatmap.Value = BeatmapManager.GetWorkingBeatmap(selection);
Logger.Log($"Completing {nameof(PresentBeatmap)} with beatmap {beatmap} ruleset {selection.Ruleset}");
}
else
{
Beatmap.Value = BeatmapManager.GetWorkingBeatmap(selection);
Logger.Log($"Completing {nameof(PresentBeatmap)} with beatmap {beatmap} (maintaining ruleset)");
}
}
}, validScreens: new[]
{
typeof(SongSelect), typeof(IHandlePresentBeatmap)
});
}
/// <summary>
/// Join a multiplayer match immediately.
/// </summary>
/// <param name="room">The room to join.</param>
/// <param name="password">The password to join the room, if any is given.</param>
public void PresentMultiplayerMatch(Room room, string password)
{
if (room.HasEnded)
{
// TODO: Eventually it should be possible to display ended multiplayer rooms in game too,
// but it generally will require turning off the entirety of communication with spectator server which is currently embedded into multiplayer screens.
Notifications.Post(new SimpleNotification
{
Text = NotificationsStrings.MultiplayerRoomEnded,
Activated = () =>
{
OpenUrlExternally($@"/multiplayer/rooms/{room.RoomID}");
return true;
}
});
return;
}
PerformFromScreen(screen =>
{
if (!(screen is Multiplayer multiplayer))
screen.Push(multiplayer = new Multiplayer());
multiplayer.Join(room, password);
});
// TODO: We should really be able to use `validScreens: new[] { typeof(Multiplayer) }` here
// but `PerformFromScreen` doesn't understand nested stacks.
}
/// <summary>
/// Join a playlist immediately.
/// </summary>
/// <param name="room">The playlist to join.</param>
public void PresentPlaylist(Room room)
{
PerformFromScreen(screen =>
{
if (!(screen is Playlists playlists))
screen.Push(playlists = new Playlists());
playlists.Join(room);
});
// TODO: We should really be able to use `validScreens: new[] { typeof(Playlists) }` here
// but `PerformFromScreen` doesn't understand nested stacks.
}
/// <summary>
/// Present a score's replay immediately.
/// The user should have already requested this interactively.
/// </summary>
public void PresentScore(IScoreInfo score, ScorePresentType presentType = ScorePresentType.Results)
{
Logger.Log($"Beginning {nameof(PresentScore)} with score {score}");
Score databasedScore;
try
{
databasedScore = ScoreManager.GetScore(score);
}
catch (LegacyScoreDecoder.BeatmapNotFoundException notFound)
{
Logger.Log("The replay cannot be played because the beatmap is missing.", LoggingTarget.Information);
var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash });
req.Success += res => Notifications.Post(new MissingBeatmapNotification(res, notFound.Hash, null));
API.Queue(req);
return;
}
if (databasedScore == null) return;
if (databasedScore.Replay == null)
{
Logger.Log("The loaded score has no replay data.", LoggingTarget.Information, LogLevel.Important);
return;
}
var databasedBeatmap = databasedScore.ScoreInfo.BeatmapInfo;
Debug.Assert(databasedBeatmap != null);
// This should be able to be performed from song select always, but that is disabled for now
// due to the weird decoupled ruleset logic (which can cause a crash in certain filter scenarios).
//
// As a special case, if the beatmap and ruleset already match, allow immediately displaying the score from song select.
// This is guaranteed to not crash, and feels better from a user's perspective (ie. if they are clicking a score in the
// song select leaderboard).
// Similar exemptions are made here for daily challenge where it is guaranteed that beatmap and ruleset match.
// `OnlinePlayScreen` is excluded because when resuming back to it,
// `RoomSubScreen` changes the global beatmap to the next playlist item on resume,
// which may not match the score, and thus crash.
IEnumerable<Type> validScreens =
Beatmap.Value.BeatmapInfo.Equals(databasedBeatmap) && Ruleset.Value.Equals(databasedScore.ScoreInfo.Ruleset)
? new[] { typeof(SongSelect), typeof(DailyChallenge) }
: Array.Empty<Type>();
PerformFromScreen(screen =>
{
Logger.Log($"{nameof(PresentScore)} updating beatmap ({databasedBeatmap}) and ruleset ({databasedScore.ScoreInfo.Ruleset}) to match score");
// some screens (mostly online) disable the ruleset/beatmap bindable.
// attempting to set the ruleset/beatmap in that state will crash.
// however, the `validScreens` pre-check above should ensure that we actually never come from one of those screens
// while simultaneously having mismatched ruleset/beatmap.
// therefore this is just a safety against touching the possibly-disabled bindables if we don't actually have to touch them.
// if it ever fails, then this probably *should* crash anyhow (so that we can fix it).
if (!Ruleset.Value.Equals(databasedScore.ScoreInfo.Ruleset))
Ruleset.Value = databasedScore.ScoreInfo.Ruleset;
if (!Beatmap.Value.BeatmapInfo.Equals(databasedBeatmap))
Beatmap.Value = BeatmapManager.GetWorkingBeatmap(databasedBeatmap);
var currentLeaderboard = LeaderboardManager.CurrentCriteria;
bool leaderboardBeatmapMatches = currentLeaderboard != null && databasedBeatmap.Equals(currentLeaderboard.Beatmap);
bool leaderboardRulesetMatches = currentLeaderboard != null && databasedScore.ScoreInfo.Ruleset.Equals(currentLeaderboard.Ruleset);
if (!leaderboardBeatmapMatches || !leaderboardRulesetMatches)
{
var newLeaderboard = currentLeaderboard != null
? currentLeaderboard with { Beatmap = databasedBeatmap, Ruleset = databasedScore.ScoreInfo.Ruleset }
: new LeaderboardCriteria(databasedBeatmap, databasedScore.ScoreInfo.Ruleset, BeatmapLeaderboardScope.Global, null);
LeaderboardManager.FetchWithCriteria(newLeaderboard);
}
switch (presentType)
{
case ScorePresentType.Gameplay:
screen.Push(new ReplayPlayerLoader(databasedScore));
break;
case ScorePresentType.Results:
screen.Push(new SoloResultsScreen(databasedScore.ScoreInfo));
break;
}
}, validScreens: validScreens);
}
public override Task Import(ImportTask[] imports, ImportParameters parameters = default)
{
// encapsulate task as we don't want to begin the import process until in a ready state.
// ReSharper disable once AsyncVoidLambda
// TODO: This is bad because `new Task` doesn't have a Func<Task?> override.
// Only used for android imports and a bit of a mess. Probably needs rethinking overall.
var importTask = new Task(async () => await base.Import(imports, parameters).ConfigureAwait(false));
waitForReady(() => this, _ => importTask.Start());
return importTask;
}
protected virtual Loader CreateLoader() => new Loader();
protected virtual UpdateManager CreateUpdateManager() => new UpdateManager();
/// <summary>
/// Adjust the globally applied <see cref="DrawSizePreservingFillContainer.TargetDrawSize"/> in every <see cref="ScalingContainer"/>.
/// Useful for changing how the game handles different aspect ratios.
/// </summary>
public virtual Vector2 ScalingContainerTargetDrawSize { get; } = new Vector2(1024, 768);
protected override Container CreateScalingContainer() => new ScalingContainer(ScalingMode.Everything);
#region Beatmap progression
private void beatmapChanged(ValueChangedEvent<WorkingBeatmap> beatmap)
{
beatmap.OldValue?.CancelAsyncLoad();
beatmap.NewValue?.BeginAsyncLoad();
updateWindowTitle();
}
private void updateWindowTitle()
{
if (Host.Window == null)
return;
string newTitle;
switch (configUserActivity.Value)
{
default:
newTitle = Name;
break;
case UserActivity.InGame:
case UserActivity.TestingBeatmap:
case UserActivity.WatchingReplay:
newTitle = $"{Name} - {Beatmap.Value.BeatmapInfo.GetDisplayTitleRomanisable(true, false)}";
break;
case UserActivity.EditingBeatmap:
newTitle = $"{Name} - {Beatmap.Value.BeatmapInfo.Path ?? "new beatmap"}";
break;
}
if (newTitle != Host.Window.Title)
Host.Window.Title = newTitle;
}
private void modsChanged(ValueChangedEvent<IReadOnlyList<Mod>> mods)
{
// a lease may be taken on the mods bindable, at which point we can't really ensure valid mods.
if (SelectedMods.Disabled)
return;
if (!ModUtils.CheckValidForGameplay(mods.NewValue, out var invalid))
{
// ensure we always have a valid set of mods.
SelectedMods.Value = mods.NewValue.Except(invalid).ToArray();
}
}
#endregion
private PerformFromMenuRunner performFromMainMenuTask;
public void PerformFromScreen(Action<IScreen> action, IEnumerable<Type> validScreens = null)
{
performFromMainMenuTask?.Cancel();
Add(performFromMainMenuTask = new PerformFromMenuRunner(action, validScreens, () => ScreenStack.CurrentScreen));
}
public override void AttemptExit()
{
// The main menu exit implementation gives the user a chance to interrupt the exit process if needed.
PerformFromScreen(menu => menu.Exit(), new[] { typeof(MainMenu) });
}
/// <summary>
/// Wait for the game (and target component) to become loaded and then run an action.
/// </summary>
/// <param name="retrieveInstance">A function to retrieve a (potentially not-yet-constructed) target instance.</param>
/// <param name="action">The action to perform on the instance when load is confirmed.</param>
/// <typeparam name="T">The type of the target instance.</typeparam>
private void waitForReady<T>(Func<T> retrieveInstance, Action<T> action)
where T : Drawable
{
var instance = retrieveInstance();
if (ScreenStack == null || ScreenStack.CurrentScreen is StartupScreen || instance?.IsLoaded != true)
Schedule(() => waitForReady(retrieveInstance, action));
else
action(instance);
}
protected override void Dispose(bool isDisposing)
{
// Without this, tests may deadlock due to cancellation token not becoming cancelled before disposal.
// To reproduce, run `TestSceneButtonSystemNavigation` ensuring `TestConstructor` runs before `TestFastShortcutKeys`.
detachedBeatmapStore?.Dispose();
base.Dispose(isDisposing);
sentryLogger.Dispose();
if (Host?.Window != null)
Host.Window.DragDrop -= onWindowDragDrop;
Logger.NewEntry -= forwardGeneralLogToNotifications;
Logger.NewEntry -= forwardTabletLogToNotifications;
}
protected override IDictionary<FrameworkSetting, object> GetFrameworkConfigDefaults()
{
return new Dictionary<FrameworkSetting, object>
{
// General expectation that osu! starts in fullscreen by default (also gives the most predictable performance).
// However, macOS is bound to have issues when using exclusive fullscreen as it takes full control away from OS, therefore borderless is default there.
{ FrameworkSetting.WindowMode, RuntimeInfo.OS == RuntimeInfo.Platform.macOS ? WindowMode.Borderless : WindowMode.Fullscreen },
{ FrameworkSetting.VolumeUniversal, 0.6 },
{ FrameworkSetting.VolumeMusic, 0.6 },
{ FrameworkSetting.VolumeEffect, 0.6 },
};
}
protected override void LoadComplete()
{
base.LoadComplete();
frameSyncMode = frameworkConfig.GetBindable<FrameSync>(FrameworkSetting.FrameSync);
frameSyncMode.BindValueChanged(_ => Schedule(updateDrawLimiter), true);
gameHost?.Window?.CurrentDisplayMode.BindValueChanged(_ => Schedule(updateDrawLimiter), true);
var languages = Enum.GetValues<Language>();
var mappings = languages.Select(language =>
{
#if DEBUG
if (language == Language.debug)
return new LocaleMapping("debug", new DebugLocalisationStore());
#endif
string cultureCode = language.ToCultureCode();
try
{
return new LocaleMapping(new ResourceManagerLocalisationStore(cultureCode));
}
catch (Exception ex)
{
Logger.Error(ex, $"Could not load localisations for language \"{cultureCode}\"");
return null;
}
}).Where(m => m != null);
Localisation.AddLocaleMappings(mappings);
// The next time this is updated is in UpdateAfterChildren, which occurs too late and results
// in the cursor being shown for a few frames during the intro.
// This prevents the cursor from showing until we have a screen with CursorVisible = true
GlobalCursorDisplay.ShowCursor = menuScreen?.CursorVisible ?? false;
// todo: all archive managers should be able to be looped here.
SkinManager.PostNotification = n => Notifications.Post(n);
SkinManager.PresentImport = items => PresentSkin(items.First().Value);
BeatmapManager.PostNotification = n => Notifications.Post(n);
BeatmapManager.PresentImport = items => PresentBeatmap(items.First().Value);
BeatmapDownloader.PostNotification = n => Notifications.Post(n);
ScoreDownloader.PostNotification = n => Notifications.Post(n);
ScoreManager.PostNotification = n => Notifications.Post(n);
ScoreManager.PresentImport = items => PresentScore(items.First().Value);
MultiplayerClient.PostNotification = n => Notifications.Post(n);
MultiplayerClient.PresentMatch = PresentMultiplayerMatch;
ScreenFooter.BackReceptor backReceptor;
dependencies.CacheAs(idleTracker = new GameIdleTracker(6000));
var sessionIdleTracker = new GameIdleTracker(300000);
sessionIdleTracker.IsIdle.BindValueChanged(idle =>
{
if (idle.NewValue)
SessionStatics.ResetAfterInactivity();
});
Add(sessionIdleTracker);
Container logoContainer;
AddRange(new Drawable[]
{
ScreenOffsetContainer = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
ScreenContainer = new ScalingContainer(ScalingMode.ExcludeOverlays)
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Children = new Drawable[]
{
backReceptor = new ScreenFooter.BackReceptor(),
ScreenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both },
logoContainer = new Container { RelativeSizeAxes = Axes.Both },
// TODO: what is this? why is this?
// TODO: this is being screen scaled even though it's probably AN OVERLAY.
footerBasedOverlayContent = new Container
{
Depth = -1,
RelativeSizeAxes = Axes.Both,
},
new PopoverContainer
{
// Ensure the footer is displayed above any content and/or overlays.
Depth = -1,
RelativeSizeAxes = Axes.Both,
Child = screenStackFooter = new ScreenStackFooter(ScreenStack, backReceptor)
{
// TODO: this is really really weird and should not exist.
RequestLogoInFront = inFront => ScreenContainer.ChangeChildDepth(logoContainer, inFront ? float.MinValue : 0),
BackButtonPressed = handleBackButton
},
},
}
},
}
},
overlayOffsetContainer = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
overlayContent = new Container { RelativeSizeAxes = Axes.Both },
leftFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both },
rightFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both },
}
},
topMostOverlayContent = new Container { RelativeSizeAxes = Axes.Both },
idleTracker,
new ConfineMouseTracker()
});
dependencies.Cache(ScreenFooter);
ScreenStack.ScreenPushed += screenPushed;
ScreenStack.ScreenExited += screenExited;
loadComponentSingleFile(fpsCounter = new FPSCounter
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding(5),
}, topMostOverlayContent.Add);
if (!IsDeployedBuild)
loadComponentSingleFile(devBuildBanner = new DevBuildBanner(), ScreenContainer.Add);
loadComponentSingleFile(osuLogo, _ =>
{
osuLogo.SetupDefaultContainer(logoContainer);
// Loader has to be created after the logo has finished loading as Loader performs logo transformations on entering.
ScreenStack.Push(CreateLoader().With(l => l.RelativeSizeAxes = Axes.Both));
});
LocalUserStatisticsProvider statisticsProvider;
loadComponentSingleFile(statisticsProvider = new LocalUserStatisticsProvider(), Add, true);
loadComponentSingleFile(difficultyRecommender = new DifficultyRecommender(statisticsProvider), Add, true);
loadComponentSingleFile(new UserStatisticsWatcher(statisticsProvider), Add, true);
loadComponentSingleFile(Toolbar = new Toolbar
{
OnHome = delegate
{
CloseAllOverlays(false);
if (menuScreen?.GetChildScreen() != null)
menuScreen.MakeCurrent();
},
}, topMostOverlayContent.Add);
loadComponentSingleFile(volume = new VolumeOverlay(), leftFloatingOverlayContent.Add, true);
onScreenDisplay = new OnScreenDisplay();
onScreenDisplay.BeginTracking(this, frameworkConfig);
onScreenDisplay.BeginTracking(this, LocalConfig);
loadComponentSingleFile(onScreenDisplay, Add, true);
loadComponentSingleFile<INotificationOverlay>(Notifications.With(d =>
{
d.Anchor = Anchor.TopRight;
d.Origin = Anchor.TopRight;
}), rightFloatingOverlayContent.Add, true);
loadComponentSingleFile(legacyImportManager, Add);
loadComponentSingleFile(screenshotManager, Add);
// dependency on notification overlay, dependent by settings overlay
loadComponentSingleFile(CreateUpdateManager(), Add, true);
// overlay elements
loadComponentSingleFile(FirstRunOverlay = new FirstRunSetupOverlay(), footerBasedOverlayContent.Add, true);
loadComponentSingleFile(new ManageCollectionsDialog(), overlayContent.Add, true);
loadComponentSingleFile(beatmapListing = new BeatmapListingOverlay(), overlayContent.Add, true);
loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true);
loadComponentSingleFile(news = new NewsOverlay(), overlayContent.Add, true);
var rankingsOverlay = loadComponentSingleFile(new RankingsOverlay(), overlayContent.Add, true);
loadComponentSingleFile(channelManager = new ChannelManager(API), Add, true);
loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true);
loadComponentSingleFile(new MessageNotifier(), Add, true);
loadComponentSingleFile(Settings = new SettingsOverlay(), leftFloatingOverlayContent.Add, true);
loadComponentSingleFile(changelogOverlay = new ChangelogOverlay(), overlayContent.Add, true);
loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true);
loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true);
loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true);
loadComponentSingleFile(skinEditor = new SkinEditorOverlay(ScreenContainer), overlayContent.Add, true);
loadComponentSingleFile(new LoginOverlay
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
}, rightFloatingOverlayContent.Add, true);
loadComponentSingleFile(new NowPlayingOverlay
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
}, rightFloatingOverlayContent.Add, true);
loadComponentSingleFile(new AccountCreationOverlay(), topMostOverlayContent.Add, true);
loadComponentSingleFile<IDialogOverlay>(new DialogOverlay(), topMostOverlayContent.Add, true);
loadComponentSingleFile(new MedalOverlay(), topMostOverlayContent.Add);
loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add);
loadComponentSingleFile(new EzAnalysisWarmupProcessor(), Add);
loadComponentSingleFile<BeatmapStore>(detachedBeatmapStore = new RealmDetachedBeatmapStore(), Add, true);
loadComponentSingleFile(new QueueController(), Add, true);
Add(externalLinkOpener = new ExternalLinkOpener());
Add(new MusicKeyBindingHandler());
Add(new OnlineStatusNotifier(() => ScreenStack.CurrentScreen));
Add(new FriendPresenceNotifier());
// side overlays which cancel each other.
var singleDisplaySideOverlays = new OverlayContainer[] { Settings, Notifications, FirstRunOverlay };
foreach (var overlay in singleDisplaySideOverlays)
{
overlay.State.ValueChanged += state =>
{
if (state.NewValue == Visibility.Hidden) return;
singleDisplaySideOverlays.Where(o => o != overlay).ForEach(o => o.Hide());
};
}
// eventually informational overlays should be displayed in a stack, but for now let's only allow one to stay open at a time.
var informationalOverlays = new OverlayContainer[] { beatmapSetOverlay, userProfile };
foreach (var overlay in informationalOverlays)
{
overlay.State.ValueChanged += state =>
{
if (state.NewValue != Visibility.Hidden)
showOverlayAboveOthers(overlay, informationalOverlays);
};
}
// ensure only one of these overlays are open at once.
var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay, wikiOverlay };
foreach (var overlay in singleDisplayOverlays)
{
overlay.State.ValueChanged += state =>
{
// informational overlays should be dismissed on a show or hide of a full overlay.
informationalOverlays.ForEach(o => o.Hide());
if (state.NewValue != Visibility.Hidden)
showOverlayAboveOthers(overlay, singleDisplayOverlays);
};
}
OverlayActivationMode.ValueChanged += mode =>
{
if (mode.NewValue != OverlayActivation.All) CloseAllOverlays();
};
// Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup.
handleStartupImport();
}
private void handleBackButton()
{
// TODO: this is SUPER SUPER bad.
// It can potentially exit the wrong screen if screens are not loaded yet.
// ScreenFooter / ScreenBackButton should be aware of which screen it is currently being handled by.
if (!(ScreenStack.CurrentScreen is IOsuScreen currentScreen)) return;
if (!((Drawable)currentScreen).IsLoaded || (currentScreen.AllowUserExit && !currentScreen.OnBackButton())) ScreenStack.Exit();
}
private void handleStartupImport()
{
if (args?.Length > 0)
{
string[] paths = args.Where(a => !a.StartsWith('-')).ToArray();
if (paths.Length > 0)
{
string firstPath = paths.First();
if (firstPath.StartsWith(OSU_PROTOCOL, StringComparison.Ordinal))
{
HandleLink(firstPath);
}
else
{
Task.Run(() => Import(paths));
}
}
}
}
private void showOverlayAboveOthers(OverlayContainer overlay, OverlayContainer[] otherOverlays)
{
otherOverlays.Where(o => o != overlay).ForEach(o => o.Hide());
Settings.Hide();
Notifications.Hide();
// Partially visible so leave it at the current depth.
if (overlay.IsPresent)
return;
// Show above all other overlays.
if (overlay.IsLoaded)
overlayContent.ChangeChildDepth(overlay, (float)-Clock.CurrentTime);
else
overlay.Depth = (float)-Clock.CurrentTime;
}
private void forwardGeneralLogToNotifications(LogEntry entry)
{
if (entry.Level < LogLevel.Important || entry.Target > LoggingTarget.Database || entry.Target == null) return;
if (entry.Exception is SentryOnlyDiagnosticsException)
return;
// Custom builds may hit server-side gating for online features.
// These messages are not actionable for end-users of this fork, so avoid spamming notifications.
if (entry.Message?.Contains("Realtime online functionality is not supported on this version of the game", StringComparison.OrdinalIgnoreCase) == true)
return;
if (entry.Message?.Contains("Please ensure that you are using the latest version of the official game releases", StringComparison.OrdinalIgnoreCase) == true)
return;
if (entry.Message?.Contains("Your score will not be submitted", StringComparison.OrdinalIgnoreCase) == true)
return;
if (entry.Message?.Contains("This is not an official build of the game", StringComparison.OrdinalIgnoreCase) == true)
return;
// Some of the above messages are logged with a blank separator line at important level.
if (string.IsNullOrWhiteSpace(entry.Message) && entry.Target == LoggingTarget.Network)
return;
const int short_term_display_limit = 3;
if (generalLogRecentCount < short_term_display_limit)
{
LocalisableString message;
if (entry.Exception != null && IsDeployedBuild)
message = LocalisableString.Interpolate($"{entry.Message.Truncate(256)}\n\n{NotificationsStrings.ErrorAutomaticallyReported}");
else
message = entry.Message.Truncate(256);
Schedule(() => Notifications.Post(new SimpleErrorNotification
{
Icon = entry.Level == LogLevel.Important ? FontAwesome.Solid.ExclamationCircle : FontAwesome.Solid.Bomb,
Text = message
}));
}
else if (generalLogRecentCount == short_term_display_limit)
{
string logFile = Logger.GetLogger(entry.Target.Value).Filename;
Schedule(() => Notifications.Post(new SimpleNotification
{
Icon = FontAwesome.Solid.EllipsisH,
Text = NotificationsStrings.SubsequentMessagesLogged,
Activated = () =>
{
Logger.Storage.PresentFileExternally(logFile);
return true;
}
}));
}
Interlocked.Increment(ref generalLogRecentCount);
Scheduler.AddDelayed(() => Interlocked.Decrement(ref generalLogRecentCount), general_log_debounce);
}
private void forwardTabletLogToNotifications(LogEntry entry)
{
if (entry.Level < LogLevel.Important || entry.Target != LoggingTarget.Input || !entry.Message.StartsWith(tablet_log_prefix, StringComparison.OrdinalIgnoreCase))
return;
string message = entry.Message.Replace(tablet_log_prefix, string.Empty);
if (entry.Level == LogLevel.Error)
{
if (!tabletLogNotifyOnError)
return;
tabletLogNotifyOnError = false;
Schedule(() =>
{
Notifications.Post(new SimpleNotification
{
Text = NotificationsStrings.TabletSupportDisabledDueToError(message),
Icon = FontAwesome.Solid.PenSquare,
IconColour = Colours.RedDark,
});
// We only have one tablet handler currently.
// The loop here is weakly guarding against a future where more than one is added.
// If this is ever the case, this logic needs adjustment as it should probably only
// disable the relevant tablet handler rather than all.
foreach (var tabletHandler in Host.AvailableInputHandlers.OfType<ITabletHandler>())
tabletHandler.Enabled.Value = false;
});
}
else if (tabletLogNotifyOnWarning)
{
Schedule(() => Notifications.Post(new SimpleNotification
{
Text = NotificationsStrings.EncounteredTabletWarning,
Icon = FontAwesome.Solid.PenSquare,
IconColour = Colours.YellowDark,
Activated = () =>
{
OpenUrlExternally("https://opentabletdriver.net/Tablets", LinkWarnMode.NeverWarn);
return true;
}
}));
tabletLogNotifyOnWarning = false;
}
}
private Task asyncLoadStream;
/// <summary>
/// Queues loading the provided component in sequential fashion.
/// This operation is limited to a single thread to avoid saturating all cores.
/// </summary>
/// <param name="component">The component to load.</param>
/// <param name="loadCompleteAction">An action to invoke on load completion (generally to add the component to the hierarchy).</param>
/// <param name="cache">Whether to cache the component as type <typeparamref name="T"/> into the game dependencies before any scheduling.</param>
private T loadComponentSingleFile<T>(T component, Action<Drawable> loadCompleteAction, bool cache = false)
where T : class
{
if (cache)
dependencies.CacheAs(component);
var drawableComponent = component as Drawable ?? throw new ArgumentException($"Component must be a {nameof(Drawable)}", nameof(component));
if (component is OsuFocusedOverlayContainer overlay)
focusedOverlays.Add(overlay);
// schedule is here to ensure that all component loads are done after LoadComplete is run (and thus all dependencies are cached).
// with some better organisation of LoadComplete to do construction and dependency caching in one step, followed by calls to loadComponentSingleFile,
// we could avoid the need for scheduling altogether.
Schedule(() =>
{
var previousLoadStream = asyncLoadStream;
// chain with existing load stream
asyncLoadStream = Task.Run(async () =>
{
if (previousLoadStream != null)
await previousLoadStream.ConfigureAwait(false);
try
{
Logger.Log($"Loading {component}...");
// Since this is running in a separate thread, it is possible for OsuGame to be disposed after LoadComponentAsync has been called
// throwing an exception. To avoid this, the call is scheduled on the update thread, which does not run if IsDisposed = true
Task task = null;
var del = new ScheduledDelegate(() => task = LoadComponentAsync(drawableComponent, loadCompleteAction));
Scheduler.Add(del);
// The delegate won't complete if OsuGame has been disposed in the meantime
while (!IsDisposed && !del.Completed)
await Task.Delay(10).ConfigureAwait(false);
// Either we're disposed or the load process has started successfully
if (IsDisposed)
return;
Debug.Assert(task != null);
await task.ConfigureAwait(false);
Logger.Log($"Loaded {component}!");
}
catch (OperationCanceledException)
{
}
});
});
return component;
}
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
switch (e.Action)
{
case GlobalAction.DecreaseVolume:
case GlobalAction.IncreaseVolume:
return volume.Adjust(e.Action);
}
// All actions below this point don't allow key repeat.
if (e.Repeat)
return false;
// Wait until we're loaded at least to the intro before allowing various interactions.
if (introScreen == null) return false;
switch (e.Action)
{
case GlobalAction.ToggleMute:
case GlobalAction.NextVolumeMeter:
case GlobalAction.PreviousVolumeMeter:
return volume.Adjust(e.Action);
case GlobalAction.ToggleFPSDisplay:
fpsCounter.ToggleVisibility();
return true;
case GlobalAction.ToggleSkinEditor:
skinEditor.ToggleVisibility();
return true;
case GlobalAction.ResetInputSettings:
Host.ResetInputHandlers();
frameworkConfig.GetBindable<ConfineMouseMode>(FrameworkSetting.ConfineMouseMode).SetDefault();
return true;
case GlobalAction.ToggleGameplayMouseButtons:
var mouseDisableButtons = LocalConfig.GetBindable<bool>(OsuSetting.MouseDisableButtons);
mouseDisableButtons.Value = !mouseDisableButtons.Value;
return true;
case GlobalAction.ToggleProfile:
if (userProfile.State.Value == Visibility.Visible)
userProfile.Hide();
else
ShowUser(API.LocalUser.Value);
return true;
case GlobalAction.RandomSkin:
// Don't allow random skin selection while in the skin editor.
// This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path.
// If people want this to work we can potentially avoid selecting default skins when the editor is open, or allow a maximum of one mutable skin somehow.
if (skinEditor.State.Value == Visibility.Visible)
return false;
SkinManager.SelectRandomSkin();
return true;
case GlobalAction.NextSkin:
if (skinEditor.State.Value == Visibility.Visible)
return false;
SkinManager.SelectNextSkin();
return true;
case GlobalAction.PreviousSkin:
if (skinEditor.State.Value == Visibility.Visible)
return false;
SkinManager.SelectPreviousSkin();
return true;
}
return false;
}
public override bool OnPressed(KeyBindingPressEvent<PlatformAction> e)
{
const float adjustment_increment = 0.05f;
switch (e.Action)
{
case PlatformAction.ZoomIn:
uiScale.Value += adjustment_increment;
return true;
case PlatformAction.ZoomOut:
uiScale.Value -= adjustment_increment;
return true;
case PlatformAction.ZoomDefault:
uiScale.SetDefault();
return true;
}
return base.OnPressed(e);
}
#region Inactive audio dimming
private readonly BindableDouble inactiveVolumeFade = new BindableDouble();
private void updateActiveState(bool isActive)
{
if (isActive)
this.TransformBindableTo(inactiveVolumeFade, 1, 400, Easing.OutQuint);
else
this.TransformBindableTo(inactiveVolumeFade, LocalConfig.Get<double>(OsuSetting.VolumeInactive), 4000, Easing.OutQuint);
}
#endregion
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
}
protected override bool OnExiting()
{
if (ScreenStack.CurrentScreen is Loader)
return false;
if (introScreen?.DidLoadMenu == true && !(ScreenStack.CurrentScreen is IntroScreen))
{
Scheduler.Add(introScreen.MakeCurrent);
return true;
}
return base.OnExiting();
}
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
ScreenOffsetContainer.Padding = new MarginPadding { Top = toolbarOffset };
overlayOffsetContainer.Padding = new MarginPadding { Top = toolbarOffset };
float horizontalOffset = 0f;
// Content.ToLocalSpace() is used instead of this.ToLocalSpace() to correctly calculate the offset with scaling modes active.
// Content is a child of a scaling container with ScalingMode.Everything set, while the game itself is never scaled.
// this avoids a visible jump in the positioning of the screen offset container.
if (Settings.IsLoaded && Settings.IsPresent)
horizontalOffset += Content.ToLocalSpace(Settings.ScreenSpaceDrawQuad.TopRight).X * SIDE_OVERLAY_OFFSET_RATIO;
if (Notifications.IsLoaded && Notifications.IsPresent)
horizontalOffset += (Content.ToLocalSpace(Notifications.ScreenSpaceDrawQuad.TopLeft).X - Content.DrawWidth) * SIDE_OVERLAY_OFFSET_RATIO;
ScreenOffsetContainer.X = horizontalOffset;
overlayContent.X = horizontalOffset * 1.2f;
GlobalCursorDisplay.ShowCursor = (ScreenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false;
}
protected virtual void ScreenChanged([CanBeNull] IOsuScreen current, [CanBeNull] IOsuScreen newScreen)
{
SentrySdk.ConfigureScope(scope =>
{
scope.Contexts[@"screen stack"] = new
{
Current = newScreen?.GetType().ReadableName(),
Previous = current?.GetType().ReadableName(),
};
scope.SetTag(@"screen", newScreen?.GetType().ReadableName() ?? @"none");
});
switch (current)
{
case Player player:
player.PlayingState.UnbindFrom(UserPlayingState);
// reset for sanity.
UserPlayingState.Value = LocalUserPlayingState.NotPlaying;
break;
}
switch (newScreen)
{
case IntroScreen intro:
introScreen = intro;
devBuildBanner?.Show();
break;
case MainMenu menu:
menuScreen = menu;
devBuildBanner?.Show();
break;
case Player player:
player.PlayingState.BindTo(UserPlayingState);
break;
default:
devBuildBanner?.Hide();
break;
}
if (current != null)
{
OverlayActivationMode.UnbindFrom(current.OverlayActivationMode);
configUserActivity.UnbindFrom(current.Activity);
}
// Bind to new screen.
if (newScreen is OsuScreen newOsuScreen)
{
OverlayActivationMode.BindTo(newScreen.OverlayActivationMode);
configUserActivity.BindTo(newScreen.Activity);
// Handle various configuration updates based on new screen settings.
GlobalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = newScreen.HideMenuCursorOnNonMouseInput;
if (newScreen.HideOverlaysOnEnter)
CloseAllOverlays();
else
Toolbar.Show();
skinEditor.SetTarget(newOsuScreen);
}
gameplayScreenActive = newScreen is Player || newScreen is PlayerLoader;
Schedule(updateDrawLimiter);
}
private void updateDrawLimiter()
{
// 暂时屏蔽测试情况
// return;
if (gameHost?.Window == null)
return;
int refreshRate = (int)MathF.Round(gameHost.Window.CurrentDisplayMode.Value.RefreshRate);
// For invalid refresh rates let's assume 60 Hz as it is most common.
if (refreshRate <= 0)
refreshRate = 120;
int drawLimiter;
bool shouldVSync;
bool shouldThrottleTextureUploads;
if (gameplayScreenActive)
{
// gameplay 期间遵循玩家配置的帧同步FrameSync
drawLimiter = refreshRate;
shouldVSync = false;
shouldThrottleTextureUploads = false;
if (frameSyncMode != null)
{
switch (frameSyncMode.Value)
{
case FrameSync.VSync:
case FrameSync.Unlimited:
drawLimiter = int.MaxValue;
shouldVSync = frameSyncMode.Value == FrameSync.VSync;
break;
case FrameSync.Limit2x:
drawLimiter *= 2;
break;
case FrameSync.Limit4x:
drawLimiter *= 4;
break;
case FrameSync.Limit8x:
drawLimiter *= 8;
break;
}
}
}
else
{
// 非 gameplay 场景强制按显示器刷新率的 4 倍绘制。
drawLimiter = refreshRate * non_gameplay_draw_multiplier;
// 额外强制关闭 VSync避免 draw 被量化到刷新率(并尽量避免 OpenGL 下类似 glFinish 的额外停顿)。
shouldVSync = false;
// UI 界面在快速滚动时可能会大量流式加载纹理(封面/背景等)。
// 通过限制“每帧上传预算”来降低帧时间尖刺。
shouldThrottleTextureUploads = true;
}
// 仅对 draw 应用与 framework 类似的“合理上限”限制。
if (!gameHost.AllowBenchmarkUnlimitedFrames)
drawLimiter = Math.Min(maximum_sane_draw_fps, drawLimiter);
gameHost.MaximumDrawHz = drawLimiter;
gameHost.SetVerticalSync(shouldVSync);
if (shouldThrottleTextureUploads)
{
// 偏保守的默认值:优先保证交互流畅,代价是缩略图/封面加载完成会稍慢。
// 前者提高随机速度,后者提高顺序速度。
gameHost.SetTextureUploadLimits(maxTexturesUploadedPerFrame: 12, maxPixelsUploadedPerFrame: 1024 * 1024);
}
else
{
gameHost.RestoreTextureUploadLimits();
}
}
private void screenPushed(IScreen lastScreen, IScreen newScreen) => ScreenChanged((OsuScreen)lastScreen, (OsuScreen)newScreen);
private void screenExited(IScreen lastScreen, IScreen newScreen)
{
ScreenChanged((OsuScreen)lastScreen, (OsuScreen)newScreen);
if (newScreen == null)
Exit();
}
}
}