diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs index 3cfbfc905a..0e37142940 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs @@ -475,7 +475,7 @@ namespace osu.Game.Rulesets.Osu.Tests performTest(hitObjects, new List { new OsuReplayFrame { Time = time_stack_start - 450, Position = new Vector2(55), Actions = { OsuAction.LeftButton } }, - }); + }, extraMods: [new OsuModNoFail()]); addClickActionAssert(0, ClickAction.Ignore); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs index b3ed4135a9..4a0f5fec6c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs @@ -189,8 +189,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("load player", () => LoadScreen(Player)); AddUntilStep("wait for loaded", () => Player.IsCurrentScreen()); AddStep("seek to 8000", () => Player.Seek(8000)); - AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed); - AddAssert("player failed after 10000", () => Player.GameplayClockContainer.CurrentTime, () => Is.GreaterThanOrEqualTo(10000)); + AddUntilStep("fail indicator visible", () => Player.ChildrenOfType().Any(indicator => indicator.IsAlive && indicator.IsPresent)); } [Test] diff --git a/osu.Game/Localisation/ReplayFailIndicatorStrings.cs b/osu.Game/Localisation/ReplayFailIndicatorStrings.cs new file mode 100644 index 0000000000..f4507a1d96 --- /dev/null +++ b/osu.Game/Localisation/ReplayFailIndicatorStrings.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class ReplayFailIndicatorStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.ReplayFailIndicator"; + + /// + /// "Replay failed" + /// + public static LocalisableString ReplayFailed => new TranslatableString(getKey(@"replay_failed"), @"Replay failed"); + + /// + /// "Go to results" + /// + public static LocalisableString GoToResults => new TranslatableString(getKey(@"go_to_results"), @"Go to results"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Rulesets/Scoring/HealthProcessor.cs b/osu.Game/Rulesets/Scoring/HealthProcessor.cs index 2799cd4b36..501b0a84bc 100644 --- a/osu.Game/Rulesets/Scoring/HealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/HealthProcessor.cs @@ -59,9 +59,16 @@ namespace osu.Game.Rulesets.Scoring protected override void RevertResultInternal(JudgementResult result) { - Health.Value = result.HealthAtJudgement; + // TODO: this is rudimentary as to make rewinding failed replays work, + // but it also acts up (sometimes rewinding a replay several times around the fail boundary moves the point of fail forward). + // needs further investigation. + if (result.FailedAtJudgement) + HasFailed = false; - // Todo: Revert HasFailed state with proper player support + if (HasFailed) + return; + + Health.Value = result.HealthAtJudgement; } /// diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 0e114b752e..9083a21704 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -56,7 +56,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { AllowPause = false, AllowRestart = false, - AllowFailAnimation = false, AllowSkipping = room.AutoSkip, AutomaticallySkipIntro = room.AutoSkip, ShowLeaderboard = true, @@ -168,6 +167,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer GameplayClockContainer.Reset(); } + protected override void PerformFail() + { + // base logic intentionally suppressed - failing in multiplayer only marks the score with F rank + ScoreProcessor.FailScore(Score.ScoreInfo); + } + + protected override void ConcludeFailedScore(Score score) + => throw new NotSupportedException($"{nameof(MultiplayerPlayer)} should never be calling {nameof(ConcludeFailedScore)}. Failing in multiplayer only marks the score with F rank."); + private void failAndBail(string? message = null) { if (!string.IsNullOrEmpty(message)) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 2a98527c16..22fb8a3463 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -932,13 +932,6 @@ namespace osu.Game.Screens.Play #region Fail Logic - /// - /// Invoked when gameplay has permanently failed. - /// - protected virtual void OnFail() - { - } - protected FailOverlay FailOverlay { get; private set; } private FailAnimationContainer failAnimationContainer; @@ -952,50 +945,57 @@ namespace osu.Game.Screens.Play 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(); - - bool restartOnFail = GameplayState.Mods.OfType().Any(m => m.RestartOnFail); - if (!restartOnFail) - 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 (restartOnFail) - Restart(true); - }); - } - else - { - ScoreProcessor.FailScore(Score.ScoreInfo); - } - + PerformFail(); return true; } + /// + /// Called when the player is determined to have failed. + /// + protected virtual void PerformFail() + { + 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(); + + bool restartOnFail = GameplayState.Mods.OfType().Any(m => m.RestartOnFail); + if (!restartOnFail) + 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(() => + { + ConcludeFailedScore(Score); + + if (restartOnFail) + Restart(true); + }); + } + + /// + /// Performs last operations on the supplied before this is definitively exited due to failing. + /// + protected virtual void ConcludeFailedScore(Score score) + { + ScoreProcessor.FailScore(score.ScoreInfo); + } + /// /// Invoked when the fail animation has finished. /// diff --git a/osu.Game/Screens/Play/PlayerConfiguration.cs b/osu.Game/Screens/Play/PlayerConfiguration.cs index cfe8a67684..f529859bfb 100644 --- a/osu.Game/Screens/Play/PlayerConfiguration.cs +++ b/osu.Game/Screens/Play/PlayerConfiguration.cs @@ -15,12 +15,6 @@ namespace osu.Game.Screens.Play /// public bool ShowResults { get; set; } = true; - /// - /// Whether the fail animation / screen should be triggered on failing. - /// If false, the score will still be marked as failed but gameplay will continue. - /// - public bool AllowFailAnimation { get; set; } = true; - /// /// Whether the player should be allowed to trigger a restart. /// diff --git a/osu.Game/Screens/Play/ReplayFailIndicator.cs b/osu.Game/Screens/Play/ReplayFailIndicator.cs new file mode 100644 index 0000000000..ee9d97a075 --- /dev/null +++ b/osu.Game/Screens/Play/ReplayFailIndicator.cs @@ -0,0 +1,171 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using ManagedBass.Fx; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Platform; +using osu.Game.Audio; +using osu.Game.Audio.Effects; +using osu.Game.Beatmaps; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Skinning; +using osuTK; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Play +{ + public partial class ReplayFailIndicator : CompositeDrawable + { + public Action? GoToResults { get; init; } + + private readonly GameplayClockContainer gameplayClockContainer; + private readonly BindableDouble trackFreq = new BindableDouble(1); + private readonly BindableDouble volumeAdjustment = new BindableDouble(1); + + private Track track = null!; + private SkinnableSound failSample = null!; + private AudioFilter failLowPassFilter = null!; + private AudioFilter failHighPassFilter = null!; + + private double? failTime; + + // relied on to make arbitrary seeks / rewinding work pretty well out-of-the-box, leveraging custom clock and absolute transform sequences + public override bool RemoveCompletedTransforms => false; + + public ReplayFailIndicator(GameplayClockContainer gameplayClockContainer) + { + AlwaysPresent = true; + Clock = this.gameplayClockContainer = gameplayClockContainer; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, AudioManager audio, IBindable beatmap, GameHost host) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + AutoSizeAxes = Axes.Both; + Alpha = 0; + + track = beatmap.Value.Track; + + RoundedButton goToResultsButton; + + InternalChildren = new Drawable[] + { + failSample = new SkinnableSound(new SampleInfo(@"Gameplay/failsound")), + failLowPassFilter = new AudioFilter(audio.TrackMixer), + failHighPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass), + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 20, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Gray3, + Alpha = 0.8f, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(20), + Spacing = new Vector2(15), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Title, + Text = ReplayFailIndicatorStrings.ReplayFailed, + }, + goToResultsButton = new RoundedButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 150, + Text = ReplayFailIndicatorStrings.GoToResults, + Action = GoToResults, + } + } + } + } + } + }; + + // every single component here is fine being synced to the gameplay clock... + // except the "go to results" button, which starts having hover animations synced to the audio track + // which is something that we don't want. + // it is maybe probably possible to restructure the drawable hierarchy here to remove the button from under the gameplay clock, + // but it would resort in uglier and more complicated drawable code. + // thus, resort to the escape hatch extension method to ensure the button specifically still runs on the game update clock. + goToResultsButton.ApplyGameWideClock(host); + + track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); + track.AddAdjustment(AdjustableProperty.Frequency, trackFreq); + } + + public void Display() + { + failTime = Clock.CurrentTime; + + using (BeginAbsoluteSequence(failTime.Value)) + { + // intentionally shorter than the actual fail animation + const double audio_sweep_duration = 1000; + + this.FadeInFromZero(200, Easing.OutQuint); + this.ScaleTo(1.1f, audio_sweep_duration, Easing.OutElasticHalf); + this.TransformBindableTo(trackFreq, 0, audio_sweep_duration); + this.TransformBindableTo(volumeAdjustment, 0.5); + failHighPassFilter.CutoffTo(300); + failLowPassFilter.CutoffTo(300, audio_sweep_duration, Easing.OutCubic); + } + } + + private bool failSamplePlaybackInitiated; + + protected override void Update() + { + base.Update(); + + // the playback of the fail sample is the one thing that cannot be easily written using rewindable transforms and such. + // this part needs to be hardcoded in update to work. + if (gameplayClockContainer.GetTrueGameplayRate() > 0 && Time.Current >= failTime && !failSamplePlaybackInitiated) + { + failSamplePlaybackInitiated = true; + failSample.Play(); + } + + if (Time.Current < failTime) + failSamplePlaybackInitiated = false; + } + + protected override void Dispose(bool isDisposing) + { + failSample.Stop(); + failSample.Dispose(); + track.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq); + track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index c058238a0a..4ed7a6061e 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -8,8 +8,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Input.Bindings; @@ -39,6 +41,7 @@ namespace osu.Game.Screens.Play private bool isAutoplayPlayback => GameplayState.Mods.OfType().Any(); private double? lastFrameTime; + private ReplayFailIndicator failIndicator; protected override bool CheckModsAllowFailure() { @@ -97,6 +100,17 @@ namespace osu.Game.Screens.Play playbackSettings.UserPlaybackRate.BindTo(master.UserPlaybackRate); HUDOverlay.PlayerSettingsOverlay.AddAtStart(playbackSettings); + HUDOverlay.Add(failIndicator = new ReplayFailIndicator(GameplayClockContainer) + { + GoToResults = () => + { + if (!this.IsCurrentScreen()) + return; + + ValidForResume = false; + this.Push(new SoloResultsScreen(Score.ScoreInfo)); + } + }); } protected override void PrepareReplay() @@ -173,5 +187,18 @@ namespace osu.Game.Screens.Play public void OnReleased(KeyBindingReleaseEvent e) { } + + protected override void PerformFail() + { + // base logic intentionally suppressed - we have our own custom fail interaction + failIndicator.Display(); + } + + public override bool OnExiting(ScreenExitEvent e) + { + // safety against filters or samples from the indicator playing long after the screen is exited + failIndicator.RemoveAndDisposeImmediately(); + return base.OnExiting(e); + } } } diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 9f0ae7168b..06f1a9c530 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -246,27 +246,26 @@ namespace osu.Game.Screens.Play return paused; } - protected override void OnFail() + protected override void ConcludeFailedScore(Score score) { - base.OnFail(); - - submitFromFailOrQuit(); + base.ConcludeFailedScore(score); + submitFromFailOrQuit(score); } public override bool OnExiting(ScreenExitEvent e) { bool exiting = base.OnExiting(e); - submitFromFailOrQuit(); + submitFromFailOrQuit(Score); statics.SetValue(Static.LastLocalUserScore, Score?.ScoreInfo.DeepClone()); return exiting; } - private void submitFromFailOrQuit() + private void submitFromFailOrQuit(Score score) { if (LoadedBeatmapSuccessfully) { // compare: https://github.com/ppy/osu/blob/ccf1acce56798497edfaf92d3ece933469edcf0a/osu.Game/Screens/Play/Player.cs#L848-L851 - var scoreCopy = Score.DeepClone(); + var scoreCopy = score.DeepClone(); Task.Run(async () => {