1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-23 01:00:28 +08:00

Merge pull request #34628 from bdach/show-results-on-failed-replay

Show indicator in replay player once replay fails
This commit is contained in:
Dean Herbert
2025-08-15 19:33:47 +09:00
committed by GitHub
Unverified
10 changed files with 296 additions and 67 deletions
@@ -475,7 +475,7 @@ namespace osu.Game.Rulesets.Osu.Tests
performTest(hitObjects, new List<ReplayFrame>
{
new OsuReplayFrame { Time = time_stack_start - 450, Position = new Vector2(55), Actions = { OsuAction.LeftButton } },
});
}, extraMods: [new OsuModNoFail()]);
addClickActionAssert(0, ClickAction.Ignore);
}
@@ -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<ReplayFailIndicator>().Any(indicator => indicator.IsAlive && indicator.IsPresent));
}
[Test]
@@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public static class ReplayFailIndicatorStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.ReplayFailIndicator";
/// <summary>
/// "Replay failed"
/// </summary>
public static LocalisableString ReplayFailed => new TranslatableString(getKey(@"replay_failed"), @"Replay failed");
/// <summary>
/// "Go to results"
/// </summary>
public static LocalisableString GoToResults => new TranslatableString(getKey(@"go_to_results"), @"Go to results");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
+9 -2
View File
@@ -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;
}
/// <summary>
@@ -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))
+48 -48
View File
@@ -932,13 +932,6 @@ namespace osu.Game.Screens.Play
#region Fail Logic
/// <summary>
/// Invoked when gameplay has permanently failed.
/// </summary>
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<IApplicableFailOverride>().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;
}
/// <summary>
/// Called when the player is determined to have failed.
/// </summary>
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<IApplicableFailOverride>().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);
});
}
/// <summary>
/// Performs last operations on the supplied <paramref name="score"/> before this <see cref="Player"/> is definitively exited due to failing.
/// </summary>
protected virtual void ConcludeFailedScore(Score score)
{
ScoreProcessor.FailScore(score.ScoreInfo);
}
/// <summary>
/// Invoked when the fail animation has finished.
/// </summary>
@@ -15,12 +15,6 @@ namespace osu.Game.Screens.Play
/// </summary>
public bool ShowResults { get; set; } = true;
/// <summary>
/// Whether the fail animation / screen should be triggered on failing.
/// If false, the score will still be marked as failed but gameplay will continue.
/// </summary>
public bool AllowFailAnimation { get; set; } = true;
/// <summary>
/// Whether the player should be allowed to trigger a restart.
/// </summary>
@@ -0,0 +1,171 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using 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<WorkingBeatmap> 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);
}
}
}
+27
View File
@@ -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<ModAutoplay>().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<GlobalAction> 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);
}
}
}
+6 -7
View File
@@ -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 () =>
{