// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Graphics.Containers; using osu.Game.IO.Archives; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Ranking; using osu.Game.Skinning; using osu.Game.Users; using osu.Game.Utils; using osuTK.Graphics; namespace osu.Game.Screens.Play { [Cached] public abstract partial class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler, ILocalUserPlayInfo { /// /// The delay upon completion of the beatmap before displaying the results screen. /// public const double RESULTS_DISPLAY_DELAY = 1000.0; /// /// Raised after is called. /// public event Action OnGameplayStarted; public override bool AllowBackButton => false; // handled by HoldForMenuButton protected override bool PlayExitSound => !isRestarting; protected override UserActivity InitialActivity => new UserActivity.InSoloGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); public override float BackgroundParallaxAmount => 0.1f; public override bool HideOverlaysOnEnter => true; public override bool HideMenuCursorOnNonMouseInput => true; protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered; // We are managing our own adjustments (see OnEntering/OnExiting). public override bool? ApplyModTrackAdjustments => false; private readonly IBindable gameActive = new Bindable(true); private readonly Bindable samplePlaybackDisabled = new Bindable(); /// /// Whether gameplay should pause when the game window focus is lost. /// protected virtual bool PauseOnFocusLost => true; public Action RestartRequested; private bool isRestarting; private Bindable mouseWheelDisabled; private readonly Bindable storyboardReplacesBackground = new Bindable(); public IBindable LocalUserPlaying => localUserPlaying; private readonly Bindable localUserPlaying = new Bindable(); private readonly Bindable playingState = new Bindable(); public int RestartCount; /// /// Whether the is currently visible. /// public IBindable ShowingOverlayComponents = new Bindable(); // Should match PlayerLoader for consistency. Cached here for the rare case we push a Player // without the loading screen (one such usage is the skin editor's scene library). [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); [Resolved] private ScoreManager scoreManager { get; set; } [Resolved] private IAPIProvider api { get; set; } [Resolved] private MusicController musicController { get; set; } [Resolved] private OsuGameBase game { get; set; } public GameplayState GameplayState { get; private set; } private Ruleset ruleset; public BreakOverlay BreakOverlay; /// /// Whether the gameplay is currently in a break. /// public readonly IBindable IsBreakTime = new BindableBool(); private BreakTracker breakTracker; private SkipOverlay skipIntroOverlay; private SkipOverlay skipOutroOverlay; protected ScoreProcessor ScoreProcessor { get; private set; } protected HealthProcessor HealthProcessor { get; private set; } protected DrawableRuleset DrawableRuleset { get; private set; } protected HUDOverlay HUDOverlay { get; private set; } public bool LoadedBeatmapSuccessfully => DrawableRuleset?.Objects.Any() == true; protected GameplayClockContainer GameplayClockContainer { get; private set; } public DimmableStoryboard DimmableStoryboard { get; private set; } /// /// Whether failing should be allowed. /// By default, this checks whether all selected mods allow failing. /// protected virtual bool CheckModsAllowFailure() => GameplayState.Mods.OfType().All(m => m.PerformFail()); public readonly PlayerConfiguration Configuration; /// /// The score for the current play session. /// Available only after the player is loaded. /// public Score Score { get; private set; } /// /// Create a new player instance. /// protected Player(PlayerConfiguration configuration = null) { Configuration = configuration ?? new PlayerConfiguration(); } private ScreenSuspensionHandler screenSuspension; private DependencyContainer dependencies; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); protected override void LoadComplete() { base.LoadComplete(); if (!LoadedBeatmapSuccessfully) return; PrepareReplay(); ScoreProcessor.NewJudgement += _ => ScoreProcessor.PopulateScore(Score.ScoreInfo); ScoreProcessor.OnResetFromReplayFrame += () => ScoreProcessor.PopulateScore(Score.ScoreInfo); gameActive.BindValueChanged(_ => updatePauseOnFocusLostState(), true); } /// /// Run any recording / playback setup for replays. /// protected virtual void PrepareReplay() { DrawableRuleset.SetRecordTarget(Score); } [BackgroundDependencyLoader(true)] private void load(OsuConfigManager config, OsuGameBase game, CancellationToken cancellationToken) { var gameplayMods = Mods.Value.Select(m => m.DeepClone()).ToArray(); if (gameplayMods.Any(m => m is UnknownMod)) { Logger.Log("Gameplay was started with an unknown mod applied.", level: LogLevel.Important); return; } if (Beatmap.Value is DummyWorkingBeatmap) return; IBeatmap playableBeatmap = loadPlayableBeatmap(gameplayMods, cancellationToken); if (playableBeatmap == null) return; if (!ModUtils.CheckModsBelongToRuleset(ruleset, gameplayMods)) { Logger.Log($@"Gameplay was started with a mod belonging to a ruleset different than '{ruleset.Description}'.", level: LogLevel.Important); return; } mouseWheelDisabled = config.GetBindable(OsuSetting.MouseDisableWheel); if (game != null) gameActive.BindTo(game.IsActive); DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, gameplayMods); dependencies.CacheAs(DrawableRuleset); if (DrawableRuleset is IDrawableScrollingRuleset scrollingRuleset) dependencies.CacheAs(scrollingRuleset.ScrollingInfo); ScoreProcessor = ruleset.CreateScoreProcessor(); ScoreProcessor.Mods.Value = gameplayMods; ScoreProcessor.ApplyBeatmap(playableBeatmap); dependencies.CacheAs(ScoreProcessor); HealthProcessor = gameplayMods.OfType().FirstOrDefault()?.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); HealthProcessor ??= ruleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); HealthProcessor.ApplyBeatmap(playableBeatmap); dependencies.CacheAs(HealthProcessor); InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime); AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer)); Score = CreateScore(playableBeatmap); // ensure the score is in a consistent state with the current player. Score.ScoreInfo.BeatmapInfo = Beatmap.Value.BeatmapInfo; Score.ScoreInfo.BeatmapHash = Beatmap.Value.BeatmapInfo.Hash; Score.ScoreInfo.Ruleset = ruleset.RulesetInfo; Score.ScoreInfo.Mods = gameplayMods; dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, HealthProcessor, Beatmap.Value.Storyboard)); var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin); // load the skinning hierarchy first. // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. GameplayClockContainer.Add(rulesetSkinProvider); if (cancellationToken.IsCancellationRequested) return; rulesetSkinProvider.AddRange(new Drawable[] { failAnimationContainer = new FailAnimationContainer(DrawableRuleset) { OnComplete = onFailComplete, Children = new[] { // underlay and gameplay should have access to the skinning sources. createUnderlayComponents(), createGameplayComponents(Beatmap.Value) } }, FailOverlay = new FailOverlay { SaveReplay = async () => await prepareAndImportScoreAsync(true).ConfigureAwait(false), OnRetry = Configuration.AllowUserInteraction ? () => Restart() : null, OnQuit = () => PerformExit(true), }, new HotkeyExitOverlay { Action = () => { if (!this.IsCurrentScreen()) return; if (PerformExit(false)) // The hotkey overlay dims the screen. // If the operation succeeds, we want to make sure we stay dimmed to keep continuity. fadeOut(true); }, }, }); if (cancellationToken.IsCancellationRequested) return; if (Configuration.AllowRestart) { rulesetSkinProvider.AddRange(new Drawable[] { new HotkeyRetryOverlay { Action = () => { if (!this.IsCurrentScreen()) return; if (Restart(true)) // The hotkey overlay dims the screen. // If the operation succeeds, we want to make sure we stay dimmed to keep continuity. fadeOut(true); }, }, }); } dependencies.CacheAs(DrawableRuleset.FrameStableClock); // add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components. // also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.) // we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there. failAnimationContainer.Add(createOverlayComponents(Beatmap.Value)); if (!DrawableRuleset.AllowGameplayOverlays) { HUDOverlay.ShowHud.Value = false; HUDOverlay.ShowHud.Disabled = true; BreakOverlay.Hide(); } DrawableRuleset.FrameStableClock.WaitingOnFrames.BindValueChanged(waiting => { if (waiting.NewValue) GameplayClockContainer.Stop(); else GameplayClockContainer.Start(); }); DrawableRuleset.IsPaused.BindValueChanged(_ => { updateGameplayState(); updateSampleDisabledState(); }); DrawableRuleset.FrameStableClock.IsCatchingUp.BindValueChanged(_ => updateSampleDisabledState()); DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updateGameplayState()); // bind clock into components that require it ((IBindable)DrawableRuleset.IsPaused).BindTo(GameplayClockContainer.IsPaused); DrawableRuleset.NewResult += r => { HealthProcessor.ApplyResult(r); ScoreProcessor.ApplyResult(r); GameplayState.ApplyResult(r); }; DrawableRuleset.RevertResult += r => { HealthProcessor.RevertResult(r); ScoreProcessor.RevertResult(r); }; DimmableStoryboard.HasStoryboardEnded.ValueChanged += _ => checkScoreCompleted(); // Bind the judgement processors to ourselves ScoreProcessor.HasCompleted.BindValueChanged(_ => checkScoreCompleted()); HealthProcessor.Failed += onFail; // Provide judgement processors to mods after they're loaded so that they're on the gameplay clock, // this is required for mods that apply transforms to these processors. ScoreProcessor.OnLoadComplete += _ => { foreach (var mod in gameplayMods.OfType()) mod.ApplyToScoreProcessor(ScoreProcessor); }; HealthProcessor.OnLoadComplete += _ => { foreach (var mod in gameplayMods.OfType()) mod.ApplyToHealthProcessor(HealthProcessor); }; IsBreakTime.BindTo(breakTracker.IsBreakTime); IsBreakTime.BindValueChanged(onBreakTimeChanged, true); loadLeaderboard(); } protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); private Drawable createUnderlayComponents() { var container = new Container { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { DimmableStoryboard = new DimmableStoryboard(GameplayState.Storyboard, GameplayState.Mods) { RelativeSizeAxes = Axes.Both }, new KiaiGameplayFountains(), }, }; return container; } private Drawable createGameplayComponents(IWorkingBeatmap working) => new ScalingContainer(ScalingMode.Gameplay) { Children = new Drawable[] { DrawableRuleset.With(r => r.FrameStableComponents.Children = new Drawable[] { ScoreProcessor, HealthProcessor, new ComboEffects(ScoreProcessor), breakTracker = new BreakTracker(DrawableRuleset.GameplayStartTime, ScoreProcessor) { Breaks = working.Beatmap.Breaks } }), } }; private Drawable createOverlayComponents(IWorkingBeatmap working) { var container = new Container { RelativeSizeAxes = Axes.Both, Children = new[] { DimmableStoryboard.OverlayLayerContainer.CreateProxy(), HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard) { HoldToQuit = { Action = () => PerformExit(true), IsPaused = { BindTarget = GameplayClockContainer.IsPaused }, ReplayLoaded = { BindTarget = DrawableRuleset.HasReplayLoaded }, }, InputCountController = { IsCounting = { Value = false }, }, Anchor = Anchor.Centre, Origin = Anchor.Centre }, BreakOverlay = new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, ScoreProcessor) { Clock = DrawableRuleset.FrameStableClock, ProcessCustomClock = false, BreakTracker = breakTracker, }, // display the cursor above some HUD elements. DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), skipIntroOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) { RequestSkip = performUserRequestedSkip }, skipOutroOverlay = new SkipOverlay(GameplayState.Storyboard.LatestEventTime ?? 0) { RequestSkip = () => progressToResults(false), Alpha = 0 }, DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(), PauseOverlay = new PauseOverlay { OnResume = Resume, Retries = RestartCount, OnRetry = () => Restart(), OnQuit = () => PerformExit(true), }, }, }; if (!Configuration.AllowSkipping || !DrawableRuleset.AllowGameplayOverlays) { skipIntroOverlay.Expire(); skipOutroOverlay.Expire(); } return container; } private void onBreakTimeChanged(ValueChangedEvent isBreakTime) { updateGameplayState(); updatePauseOnFocusLostState(); HUDOverlay.InputCountController.IsCounting.Value = !isBreakTime.NewValue; } private void updateGameplayState() { bool inGameplay = !DrawableRuleset.HasReplayLoaded.Value && !GameplayState.HasPassed && !GameplayState.HasFailed; bool inBreak = breakTracker.IsBreakTime.Value || DrawableRuleset.IsPaused.Value; if (inGameplay) playingState.Value = inBreak ? LocalUserPlayingState.Break : LocalUserPlayingState.Playing; else playingState.Value = LocalUserPlayingState.NotPlaying; localUserPlaying.Value = playingState.Value == LocalUserPlayingState.Playing; OverlayActivationMode.Value = playingState.Value == LocalUserPlayingState.Playing ? OverlayActivation.Disabled : OverlayActivation.UserTriggered; } private void updateSampleDisabledState() { samplePlaybackDisabled.Value = DrawableRuleset.FrameStableClock.IsCatchingUp.Value || GameplayClockContainer.IsPaused.Value; } private void updatePauseOnFocusLostState() { if (!PauseOnFocusLost || !pausingSupportedByCurrentState || breakTracker.IsBreakTime.Value) return; if (gameActive.Value == false) { bool paused = Pause(); // if the initial pause could not be satisfied, the pause cooldown may be active. // reschedule the pause attempt until it can be achieved. if (!paused) Scheduler.AddOnce(updatePauseOnFocusLostState); } } private IBeatmap loadPlayableBeatmap(Mod[] gameplayMods, CancellationToken cancellationToken) { IBeatmap playable; try { if (Beatmap.Value.Beatmap == null) throw new InvalidOperationException("Beatmap was not loaded"); var rulesetInfo = Ruleset.Value ?? Beatmap.Value.BeatmapInfo.Ruleset; ruleset = rulesetInfo.CreateInstance(); if (ruleset == null) throw new RulesetLoadException("Instantiation failure"); try { playable = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, gameplayMods, cancellationToken); } catch (BeatmapInvalidForRulesetException) { Logger.Log($"The current beatmap is not playable in {ruleset.RulesetInfo.Name}!", level: LogLevel.Important); return null; } if (playable.HitObjects.Count == 0) { Logger.Log("Beatmap contains no hit objects!", level: LogLevel.Important); return null; } } catch (OperationCanceledException) { // Load has been cancelled. No logging is required. return null; } catch (Exception e) { Logger.Error(e, "Could not load beatmap successfully!"); //couldn't load, hard abort! return null; } return playable; } /// /// Attempts to complete a user request to exit gameplay. /// /// /// /// This should only be called in response to a user interaction. Exiting is not guaranteed. /// This will interrupt any pending progression to the results screen, even if the transition has begun. /// /// /// /// Whether the pause or fail dialog should be shown before performing an exit. /// If and a dialog is not yet displayed, the exit will be blocked and the relevant dialog will display instead. /// /// Whether this call resulted in a final exit. protected bool PerformExit(bool showDialogFirst) { bool pauseOrFailDialogVisible = PauseOverlay.State.Value == Visibility.Visible || FailOverlay.State.Value == Visibility.Visible; if (showDialogFirst && !pauseOrFailDialogVisible) { // if the fail animation is currently in progress, accelerate it (it will show the pause dialog on completion). if (ValidForResume && GameplayState.HasFailed) { failAnimationContainer.FinishTransforms(true); return false; } // even if this call has requested a dialog, there is a chance the current player mode doesn't support pausing. if (pausingSupportedByCurrentState) { // in the case a dialog needs to be shown, attempt to pause and show it. // this may fail (see internal checks in Pause()) but the fail cases are temporary, so don't fall through to Exit(). Pause(); return false; } } // Matching osu!stable behaviour, if the results screen is pending and the user requests an exit, // show the results instead. if (GameplayState.HasPassed && !isRestarting) { progressToResults(false); return false; } // import current score if possible. prepareAndImportScoreAsync(); // Screen may not be current if a restart has been performed. if (this.IsCurrentScreen()) { // The actual exit is performed if // - the pause / fail dialog was not requested // - the pause / fail dialog was requested but is already displayed (user showing intention to exit). // - the pause / fail dialog was requested but couldn't be displayed due to the type or state of this Player instance. this.Exit(); } return true; } private void performUserRequestedSkip() { // user requested skip // disable sample playback to stop currently playing samples and perform skip samplePlaybackDisabled.Value = true; (GameplayClockContainer as MasterGameplayClockContainer)?.Skip(); // return samplePlaybackDisabled.Value to what is defined by the beatmap's current state updateSampleDisabledState(); } /// /// Seek to a specific time in gameplay. /// /// The destination time to seek to. public void Seek(double time) => GameplayClockContainer.Seek(time); private ScheduledDelegate frameStablePlaybackResetDelegate; /// /// Specify and seek to a custom start time from which gameplay should be observed. /// /// /// This performs a non-frame-stable seek. Intermediate hitobject judgements may not be applied or reverted correctly during this seek. /// /// The destination time to seek to. protected void SetGameplayStartTime(double time) { if (frameStablePlaybackResetDelegate?.Cancelled == false && !frameStablePlaybackResetDelegate.Completed) frameStablePlaybackResetDelegate.RunTask(); bool wasFrameStable = DrawableRuleset.FrameStablePlayback; DrawableRuleset.FrameStablePlayback = false; GameplayClockContainer.Reset(time); // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek. frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable); } /// /// Restart gameplay via a parent . /// This can be called from a child screen in order to trigger the restart process. /// /// Whether a quick restart was requested (skipping intro etc.). /// Whether this call resulted in a restart. public bool Restart(bool quickRestart = false) { if (!Configuration.AllowRestart) return false; isRestarting = true; // at the point of restarting the track should either already be paused or the volume should be zero. // stopping here is to ensure music doesn't become audible after exiting back to PlayerLoader. musicController.Stop(); RestartRequested?.Invoke(quickRestart); return PerformExit(false); } /// /// This delegate, when set, means the results screen has been queued to appear. /// The display of the results screen may be delayed by any work being done in . /// /// /// Once set, this can *only* be cancelled by rewinding, ie. if ScoreProcessor.HasCompleted becomes . /// Even if the user requests an exit, it will forcefully proceed to the results screen (see special case in ). /// private ScheduledDelegate resultsDisplayDelegate; /// /// A task which asynchronously prepares a completed score for display at results. /// This may include performing net requests or importing the score into the database, generally to ensure things are in a sane state for the play session. /// private Task prepareScoreForDisplayTask; /// /// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime. /// private void checkScoreCompleted() { // If this player instance is in the middle of an exit, don't attempt any kind of state update. if (!this.IsCurrentScreen()) return; // Handle cases of arriving at this method when not in a completed state. // - When a storyboard completion triggered this call earlier than gameplay finishes. // - When a replay has been rewound before a queued resultsDisplayDelegate has run. // // Currently, even if this scenario is hit, prepareAndImportScoreAsync has already been queued (and potentially run). // In the scenarios above, this is a non-issue, but it still feels a bit convoluted to have to cancel in this method. // Maybe this can be improved with further refactoring. if (!ScoreProcessor.HasCompleted.Value) { resultsDisplayDelegate?.Cancel(); resultsDisplayDelegate = null; GameplayState.HasPassed = false; ValidForResume = true; skipOutroOverlay.Hide(); return; } // Only show the completion screen if the player hasn't failed if (GameplayState.HasFailed) return; GameplayState.HasPassed = true; // Setting this early in the process means that even if something were to go wrong in the order of events following, there // is no chance that a user could return to the (already completed) Player instance from a child screen. ValidForResume = false; bool storyboardStillRunning = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value; // If the current beatmap has a storyboard, this method will be called again on storyboard completion. // Alternatively, the user may press the outro skip button, forcing immediate display of the results screen. if (storyboardStillRunning) { skipOutroOverlay.Show(); return; } progressToResults(true); } /// /// Queue the results screen for display. /// /// /// A final display will only occur once all work is completed in . This means that even after calling this method, the results screen will never be shown until ScoreProcessor.HasCompleted becomes . /// /// Whether a minimum delay () should be added before the screen is displayed. private void progressToResults(bool withDelay) { if (!Configuration.ShowResults) return; // Setting this early in the process means that even if something were to go wrong in the order of events following, there // is no chance that a user could return to the (already completed) Player instance from a child screen. ValidForResume = false; double delay = withDelay ? RESULTS_DISPLAY_DELAY : 0; resultsDisplayDelegate?.Cancel(); resultsDisplayDelegate = new ScheduledDelegate(() => { if (prepareScoreForDisplayTask == null) { // Try importing score since the task hasn't been invoked yet. prepareAndImportScoreAsync(); return; } if (!prepareScoreForDisplayTask.IsCompleted) // If the asynchronous preparation has not completed, keep repeating this delegate. return; resultsDisplayDelegate?.Cancel(); if (prepareScoreForDisplayTask.GetResultSafely() == null) { // If score import did not occur, we do not want to show the results screen. return; } if (!this.IsCurrentScreen()) // This player instance may already be in the process of exiting. return; this.Push(CreateResults(prepareScoreForDisplayTask.GetResultSafely())); }, Time.Current + delay, 50); Scheduler.Add(resultsDisplayDelegate); } /// /// Asynchronously run score preparation operations (database import, online submission etc.). /// /// Whether the score should be imported even if non-passing (or the current configuration doesn't allow for it). /// The final score. [ItemCanBeNull] private Task prepareAndImportScoreAsync(bool forceImport = false) { // Ensure we are not writing to the replay any more, as we are about to consume and store the score. DrawableRuleset.SetRecordTarget(null); if (prepareScoreForDisplayTask != null) return prepareScoreForDisplayTask; // We do not want to import the score in cases where we don't show results bool canShowResults = Configuration.ShowResults && ScoreProcessor.HasCompleted.Value && GameplayState.HasPassed; if (!canShowResults && !forceImport) return Task.FromResult(null); // Clone score before beginning any async processing. // - Must be run synchronously as the score may potentially be mutated in the background. // - Must be cloned for the same reason. Score scoreCopy = Score.DeepClone(); return prepareScoreForDisplayTask = Task.Run(async () => { try { await PrepareScoreForResultsAsync(scoreCopy).ConfigureAwait(false); } catch (Exception ex) { Logger.Error(ex, @"Score preparation failed!"); } try { await ImportScore(scoreCopy).ConfigureAwait(false); } catch (Exception ex) { Logger.Error(ex, @"Score import failed!"); } return scoreCopy.ScoreInfo; }); } protected override bool OnScroll(ScrollEvent e) { // During pause, allow global volume adjust regardless of settings. if (GameplayClockContainer.IsPaused.Value) return false; // Block global volume adjust if the user has asked for it (special case when holding "Alt"). return mouseWheelDisabled.Value && !e.AltPressed; } #region Gameplay leaderboard protected readonly Bindable LeaderboardExpandedState = new BindableBool(); private void loadLeaderboard() { HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState()); LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true); var gameplayLeaderboard = CreateGameplayLeaderboard(); if (gameplayLeaderboard != null) { LoadComponentAsync(gameplayLeaderboard, leaderboard => { if (!LoadedBeatmapSuccessfully) return; leaderboard.Expanded.BindTo(LeaderboardExpandedState); AddLeaderboardToHUD(leaderboard); }); } } [CanBeNull] protected virtual GameplayLeaderboard CreateGameplayLeaderboard() => null; protected virtual void AddLeaderboardToHUD(GameplayLeaderboard leaderboard) => HUDOverlay.LeaderboardFlow.Add(leaderboard); private void updateLeaderboardExpandedState() => LeaderboardExpandedState.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value; #endregion #region Fail Logic /// /// Invoked when gameplay has permanently failed. /// protected virtual void OnFail() { } protected FailOverlay FailOverlay { get; private set; } private FailAnimationContainer failAnimationContainer; private bool onFail() { // Failing after the quit sequence has started may cause weird side effects with the fail animation / effects. if (GameplayState.HasQuit) return false; if (!CheckModsAllowFailure()) return false; if (Configuration.AllowFailAnimation) { Debug.Assert(!GameplayState.HasFailed); Debug.Assert(!GameplayState.HasPassed); Debug.Assert(!GameplayState.HasQuit); GameplayState.HasFailed = true; updateGameplayState(); // There is a chance that we could be in a paused state as the ruleset's internal clock (see FrameStabilityContainer) // could process an extra frame after the GameplayClock is stopped. // In such cases we want the fail state to precede a user triggered pause. if (PauseOverlay.State.Value == Visibility.Visible) PauseOverlay.Hide(); failAnimationContainer.Start(); // Failures can be triggered either by a judgement, or by a mod. // // For the case of a judgement, due to ordering considerations, ScoreProcessor will not have received // the final judgement which triggered the failure yet (see DrawableRuleset.NewResult handling above). // // A schedule here ensures that any lingering judgements from the current frame are applied before we // finalise the score as "failed". Schedule(() => { ScoreProcessor.FailScore(Score.ScoreInfo); OnFail(); if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail)) Restart(true); }); } else { ScoreProcessor.FailScore(Score.ScoreInfo); } return true; } /// /// Invoked when the fail animation has finished. /// private void onFailComplete() { GameplayClockContainer.Stop(); FailOverlay.Retries = RestartCount; FailOverlay.Show(); } #endregion #region Pause Logic public bool IsResuming { get; private set; } /// /// The amount of gameplay time after which a second pause is allowed. /// protected virtual double PauseCooldownDuration => 1000; protected PauseOverlay PauseOverlay { get; private set; } private double? lastPauseActionTime; protected bool PauseCooldownActive => lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + PauseCooldownDuration; /// /// A set of conditionals which defines whether the current game state and configuration allows for /// pausing to be attempted via . If false, the game should generally exit if a user pause /// is attempted. /// private bool pausingSupportedByCurrentState => // must pass basic screen conditions (beatmap loaded, instance allows pause) LoadedBeatmapSuccessfully && Configuration.AllowPause && ValidForResume // replays cannot be paused and exit immediately && !DrawableRuleset.HasReplayLoaded.Value // cannot pause if we are already in a fail state && !GameplayState.HasFailed; private bool canResume => // cannot resume from a non-paused state GameplayClockContainer.IsPaused.Value // cannot resume if we are already in a fail state && !GameplayState.HasFailed // already resuming && !IsResuming; public bool Pause() { if (!pausingSupportedByCurrentState) return false; if (!IsResuming && PauseCooldownActive) return false; if (IsResuming) { DrawableRuleset.CancelResume(); IsResuming = false; } GameplayClockContainer.Stop(); PauseOverlay.Show(); lastPauseActionTime = GameplayClockContainer.CurrentTime; return true; } public void Resume() { if (!canResume) return; IsResuming = true; PauseOverlay.Hide(); // breaks and time-based conditions may allow instant resume. if (breakTracker.IsBreakTime.Value) completeResume(); else DrawableRuleset.RequestResume(completeResume); void completeResume() { GameplayClockContainer.Start(); IsResuming = false; } } #endregion #region Screen Logic public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); if (!LoadedBeatmapSuccessfully) return; Alpha = 0; this .ScaleTo(0.7f) .ScaleTo(1, 750, Easing.OutQuint) .Delay(250) .FadeIn(250); ApplyToBackground(b => { b.IgnoreUserSettings.Value = false; b.BlurAmount.Value = 0; b.FadeColour(Color4.White, 250); // bind component bindables. ((IBindable)b.IsBreakTime).BindTo(breakTracker.IsBreakTime); b.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); failAnimationContainer.Background = b; }); HUDOverlay.IsPlaying.BindTo(localUserPlaying); ShowingOverlayComponents.BindTo(HUDOverlay.ShowHud); DimmableStoryboard.IsBreakTime.BindTo(breakTracker.IsBreakTime); storyboardReplacesBackground.Value = GameplayState.Storyboard.ReplacesBackground && GameplayState.Storyboard.HasDrawable; foreach (var mod in GameplayState.Mods.OfType()) mod.ApplyToPlayer(this); foreach (var mod in GameplayState.Mods.OfType()) mod.ApplyToHUD(HUDOverlay); foreach (var mod in GameplayState.Mods.OfType()) mod.ApplyToTrack(GameplayClockContainer.AdjustmentsFromMods); updateGameplayState(); GameplayClockContainer.FadeInFromZero(750, Easing.OutQuint); StartGameplay(); OnGameplayStarted?.Invoke(); } /// /// Called to trigger the starting of the gameplay clock and underlying gameplay. /// This will be called on entering the player screen once. A derived class may block the first call to this to delay the start of gameplay. /// protected virtual void StartGameplay() { if (GameplayClockContainer.IsRunning) Logger.Error(new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running"), "Clock failure"); GameplayClockContainer.Reset(startClock: true); if (Configuration.AutomaticallySkipIntro) skipIntroOverlay.SkipWhenReady(); } public override void OnSuspending(ScreenTransitionEvent e) { screenSuspension?.RemoveAndDisposeImmediately(); fadeOut(); base.OnSuspending(e); } public override bool OnExiting(ScreenExitEvent e) { screenSuspension?.RemoveAndDisposeImmediately(); // Eagerly clean these up as disposal of child components is asynchronous and may leave sounds playing beyond user expectations. failAnimationContainer?.Stop(); PauseOverlay?.StopAllSamples(); if (LoadedBeatmapSuccessfully && !GameplayState.HasPassed) { Debug.Assert(resultsDisplayDelegate == null); if (!GameplayState.HasFailed) GameplayState.HasQuit = true; if (DrawableRuleset.ReplayScore == null) ScoreProcessor.FailScore(Score.ScoreInfo); } // GameplayClockContainer performs seeks / start / stop operations on the beatmap's track. // as we are no longer the current screen, we cannot guarantee the track is still usable. (GameplayClockContainer as MasterGameplayClockContainer)?.StopUsingBeatmapClock(); musicController.ResetTrackAdjustments(); fadeOut(); return base.OnExiting(e); } /// /// Creates the player's . /// /// /// The . protected virtual Score CreateScore(IBeatmap beatmap) => new Score { ScoreInfo = new ScoreInfo { User = api.LocalUser.Value, ClientVersion = game.Version, }, }; /// /// Imports the player's to the local database. /// /// The to import. /// The imported score. protected virtual Task ImportScore(Score score) { // Replays are already populated and present in the game's database, so should not be re-imported. if (DrawableRuleset.ReplayScore != null) return Task.CompletedTask; ByteArrayArchiveReader replayReader = null; if (score.ScoreInfo.Ruleset.IsLegacyRuleset()) { using (var stream = new MemoryStream()) { new LegacyScoreEncoder(score, GameplayState.Beatmap).Encode(stream); replayReader = new ByteArrayArchiveReader(stream.ToArray(), "replay.osr"); } } // the import process will re-attach managed beatmap/rulesets to this score. we don't want this for now, so create a temporary copy to import. var importableScore = score.ScoreInfo.DeepClone(); var imported = scoreManager.Import(importableScore, replayReader); Debug.Assert(imported != null); imported.PerformRead(s => { // because of the clone above, it's required that we copy back the post-import hash/ID to use for availability matching. score.ScoreInfo.Hash = s.Hash; score.ScoreInfo.ID = s.ID; score.ScoreInfo.Files.AddRange(s.Files.Detach()); }); return Task.CompletedTask; } /// /// Prepare the for display at results. /// /// The to prepare. /// A task that prepares the provided score. On completion, the score is assumed to be ready for display. protected virtual Task PrepareScoreForResultsAsync(Score score) => Task.CompletedTask; /// /// Creates the for a . /// /// The to be displayed in the results screen. /// The . protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score) { AllowRetry = true, ShowUserStatistics = true, }; private void fadeOut(bool instant = false) { float fadeOutDuration = instant ? 0 : 250; this.FadeOut(fadeOutDuration); if (this.IsCurrentScreen()) { ApplyToBackground(b => { b.IgnoreUserSettings.Value = true; // May be null if the load never completed. if (breakTracker != null) { b.IsBreakTime.UnbindFrom(breakTracker.IsBreakTime); b.IsBreakTime.Value = false; } }); storyboardReplacesBackground.Value = false; } } #endregion IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; public IBindable PlayingState => playingState; } }