From 85d48f5df66fc8d99966d7492df988b181234ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 8 Aug 2025 14:44:53 +0200 Subject: [PATCH 1/4] Refactor fail management to be more centralised Before this commit, there was `AllowFailAnimation` (used by multiplayer) and `OnFail()` (used by score submitting implementations of player to ensure a failed play submits). The former is replaced by `PerformFail()` which allows for arbitrary operations on failure, which replay player shall leverage in subsequent commits. The latter would ideally be replaced by nothing, but it's placed in a very awkward place behind a schedule, so by force of necessity to avoid code duplication it's replaced by `ConcludeFailedScore()` which is overridden to submit the score in all submitting players --- except for multiplayer, which is never supposed to be calling it, so in that case it just throws an exception. --- .../Multiplayer/MultiplayerPlayer.cs | 10 +- osu.Game/Screens/Play/Player.cs | 96 +++++++++---------- osu.Game/Screens/Play/PlayerConfiguration.cs | 6 -- osu.Game/Screens/Play/ReplayPlayer.cs | 6 ++ osu.Game/Screens/Play/SubmittingPlayer.cs | 13 ++- 5 files changed, 69 insertions(+), 62 deletions(-) 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/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index c058238a0a..c552dafc3b 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using osu.Framework.Allocation; 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; @@ -173,5 +174,10 @@ namespace osu.Game.Screens.Play public void OnReleased(KeyBindingReleaseEvent e) { } + + protected override void PerformFail() + { + // base logic intentionally suppressed + } } } 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 () => { From 35a472ae399f61c734c772bb36e1a5a1c7266850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 11 Aug 2025 14:28:50 +0200 Subject: [PATCH 2/4] Show indicator in replay player once replay fails This indicator allows the player to either rewind to an earlier part of the replay, or to proceed to results. It also plays a shortened variant of the failure animation SFX. --- .../Visual/Gameplay/TestSceneReplayPlayer.cs | 3 +- .../ReplayFailIndicatorStrings.cs | 24 +++ osu.Game/Screens/Play/ReplayFailIndicator.cs | 171 ++++++++++++++++++ osu.Game/Screens/Play/ReplayPlayer.cs | 23 ++- 4 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 osu.Game/Localisation/ReplayFailIndicatorStrings.cs create mode 100644 osu.Game/Screens/Play/ReplayFailIndicator.cs 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/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 c552dafc3b..4ed7a6061e 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -8,6 +8,7 @@ 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; @@ -40,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() { @@ -98,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() @@ -177,7 +190,15 @@ namespace osu.Game.Screens.Play protected override void PerformFail() { - // base logic intentionally suppressed + // 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); } } } From 30857de55e604e1e6fb8f9f3f777e95b1b752a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 12 Aug 2025 12:38:36 +0200 Subject: [PATCH 3/4] Add rudimentary support of rewinding failed state to health processor As indicated in the inline comment this is very best effort, just to make the HP bar not very obviously stuck in a very obviously incorrect state after the replay is rewound from a failure. There's likely to be a bunch of replay accuracy issues related to this, but I'm just making the minimum effort to get this to work semi-acceptably for now. --- osu.Game/Rulesets/Scoring/HealthProcessor.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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; } /// From 0cd4fbb41603cdacc820de2a42b3469444ea226f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 12 Aug 2025 14:16:26 +0200 Subject: [PATCH 4/4] Fix failing test --- osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); }