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:
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user