Skip to content

Commit 35a472a

Browse files
committed
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.
1 parent 85d48f5 commit 35a472a

File tree

4 files changed

+218
-3
lines changed

4 files changed

+218
-3
lines changed

osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,7 @@ public void TestReplayDoesNotFailUntilRunningOutOfFrames()
189189
AddStep("load player", () => LoadScreen(Player));
190190
AddUntilStep("wait for loaded", () => Player.IsCurrentScreen());
191191
AddStep("seek to 8000", () => Player.Seek(8000));
192-
AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed);
193-
AddAssert("player failed after 10000", () => Player.GameplayClockContainer.CurrentTime, () => Is.GreaterThanOrEqualTo(10000));
192+
AddUntilStep("fail indicator visible", () => Player.ChildrenOfType<ReplayFailIndicator>().Any(indicator => indicator.IsAlive && indicator.IsPresent));
194193
}
195194

196195
[Test]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using osu.Framework.Localisation;
5+
6+
namespace osu.Game.Localisation
7+
{
8+
public static class ReplayFailIndicatorStrings
9+
{
10+
private const string prefix = @"osu.Game.Resources.Localisation.ReplayFailIndicator";
11+
12+
/// <summary>
13+
/// "Replay failed"
14+
/// </summary>
15+
public static LocalisableString ReplayFailed => new TranslatableString(getKey(@"replay_failed"), @"Replay failed");
16+
17+
/// <summary>
18+
/// "Go to results"
19+
/// </summary>
20+
public static LocalisableString GoToResults => new TranslatableString(getKey(@"go_to_results"), @"Go to results");
21+
22+
private static string getKey(string key) => $@"{prefix}:{key}";
23+
}
24+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using System;
5+
using ManagedBass.Fx;
6+
using osu.Framework.Allocation;
7+
using osu.Framework.Audio;
8+
using osu.Framework.Audio.Track;
9+
using osu.Framework.Bindables;
10+
using osu.Framework.Graphics;
11+
using osu.Framework.Graphics.Containers;
12+
using osu.Framework.Graphics.Shapes;
13+
using osu.Framework.Platform;
14+
using osu.Game.Audio;
15+
using osu.Game.Audio.Effects;
16+
using osu.Game.Beatmaps;
17+
using osu.Game.Extensions;
18+
using osu.Game.Graphics;
19+
using osu.Game.Graphics.Sprites;
20+
using osu.Game.Graphics.UserInterfaceV2;
21+
using osu.Game.Skinning;
22+
using osuTK;
23+
using osu.Game.Localisation;
24+
25+
namespace osu.Game.Screens.Play
26+
{
27+
public partial class ReplayFailIndicator : CompositeDrawable
28+
{
29+
public Action? GoToResults { get; init; }
30+
31+
private readonly GameplayClockContainer gameplayClockContainer;
32+
private readonly BindableDouble trackFreq = new BindableDouble(1);
33+
private readonly BindableDouble volumeAdjustment = new BindableDouble(1);
34+
35+
private Track track = null!;
36+
private SkinnableSound failSample = null!;
37+
private AudioFilter failLowPassFilter = null!;
38+
private AudioFilter failHighPassFilter = null!;
39+
40+
private double? failTime;
41+
42+
// relied on to make arbitrary seeks / rewinding work pretty well out-of-the-box, leveraging custom clock and absolute transform sequences
43+
public override bool RemoveCompletedTransforms => false;
44+
45+
public ReplayFailIndicator(GameplayClockContainer gameplayClockContainer)
46+
{
47+
AlwaysPresent = true;
48+
Clock = this.gameplayClockContainer = gameplayClockContainer;
49+
}
50+
51+
[BackgroundDependencyLoader]
52+
private void load(OsuColour colours, AudioManager audio, IBindable<WorkingBeatmap> beatmap, GameHost host)
53+
{
54+
Anchor = Anchor.Centre;
55+
Origin = Anchor.Centre;
56+
AutoSizeAxes = Axes.Both;
57+
Alpha = 0;
58+
59+
track = beatmap.Value.Track;
60+
61+
RoundedButton goToResultsButton;
62+
63+
InternalChildren = new Drawable[]
64+
{
65+
failSample = new SkinnableSound(new SampleInfo(@"Gameplay/failsound")),
66+
failLowPassFilter = new AudioFilter(audio.TrackMixer),
67+
failHighPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass),
68+
new Container
69+
{
70+
Anchor = Anchor.Centre,
71+
Origin = Anchor.Centre,
72+
AutoSizeAxes = Axes.Both,
73+
Masking = true,
74+
CornerRadius = 20,
75+
Children = new Drawable[]
76+
{
77+
new Box
78+
{
79+
RelativeSizeAxes = Axes.Both,
80+
Colour = colours.Gray3,
81+
Alpha = 0.8f,
82+
},
83+
new FillFlowContainer
84+
{
85+
AutoSizeAxes = Axes.Both,
86+
Direction = FillDirection.Vertical,
87+
Anchor = Anchor.Centre,
88+
Origin = Anchor.Centre,
89+
Padding = new MarginPadding(20),
90+
Spacing = new Vector2(15),
91+
Children = new Drawable[]
92+
{
93+
new OsuSpriteText
94+
{
95+
Anchor = Anchor.Centre,
96+
Origin = Anchor.Centre,
97+
Font = OsuFont.Style.Title,
98+
Text = ReplayFailIndicatorStrings.ReplayFailed,
99+
},
100+
goToResultsButton = new RoundedButton
101+
{
102+
Anchor = Anchor.Centre,
103+
Origin = Anchor.Centre,
104+
Width = 150,
105+
Text = ReplayFailIndicatorStrings.GoToResults,
106+
Action = GoToResults,
107+
}
108+
}
109+
}
110+
}
111+
}
112+
};
113+
114+
// every single component here is fine being synced to the gameplay clock...
115+
// except the "go to results" button, which starts having hover animations synced to the audio track
116+
// which is something that we don't want.
117+
// it is maybe probably possible to restructure the drawable hierarchy here to remove the button from under the gameplay clock,
118+
// but it would resort in uglier and more complicated drawable code.
119+
// thus, resort to the escape hatch extension method to ensure the button specifically still runs on the game update clock.
120+
goToResultsButton.ApplyGameWideClock(host);
121+
122+
track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment);
123+
track.AddAdjustment(AdjustableProperty.Frequency, trackFreq);
124+
}
125+
126+
public void Display()
127+
{
128+
failTime = Clock.CurrentTime;
129+
130+
using (BeginAbsoluteSequence(failTime.Value))
131+
{
132+
// intentionally shorter than the actual fail animation
133+
const double audio_sweep_duration = 1000;
134+
135+
this.FadeInFromZero(200, Easing.OutQuint);
136+
this.ScaleTo(1.1f, audio_sweep_duration, Easing.OutElasticHalf);
137+
this.TransformBindableTo(trackFreq, 0, audio_sweep_duration);
138+
this.TransformBindableTo(volumeAdjustment, 0.5);
139+
failHighPassFilter.CutoffTo(300);
140+
failLowPassFilter.CutoffTo(300, audio_sweep_duration, Easing.OutCubic);
141+
}
142+
}
143+
144+
private bool failSamplePlaybackInitiated;
145+
146+
protected override void Update()
147+
{
148+
base.Update();
149+
150+
// the playback of the fail sample is the one thing that cannot be easily written using rewindable transforms and such.
151+
// this part needs to be hardcoded in update to work.
152+
if (gameplayClockContainer.GetTrueGameplayRate() > 0 && Time.Current >= failTime && !failSamplePlaybackInitiated)
153+
{
154+
failSamplePlaybackInitiated = true;
155+
failSample.Play();
156+
}
157+
158+
if (Time.Current < failTime)
159+
failSamplePlaybackInitiated = false;
160+
}
161+
162+
protected override void Dispose(bool isDisposing)
163+
{
164+
failSample.Stop();
165+
failSample.Dispose();
166+
track.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq);
167+
track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment);
168+
base.Dispose(isDisposing);
169+
}
170+
}
171+
}

osu.Game/Screens/Play/ReplayPlayer.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Linq;
99
using System.Threading.Tasks;
1010
using osu.Framework.Allocation;
11+
using osu.Framework.Graphics;
1112
using osu.Framework.Input.Bindings;
1213
using osu.Framework.Input.Events;
1314
using osu.Framework.Screens;
@@ -40,6 +41,7 @@ public partial class ReplayPlayer : Player, IKeyBindingHandler<GlobalAction>
4041
private bool isAutoplayPlayback => GameplayState.Mods.OfType<ModAutoplay>().Any();
4142

4243
private double? lastFrameTime;
44+
private ReplayFailIndicator failIndicator;
4345

4446
protected override bool CheckModsAllowFailure()
4547
{
@@ -98,6 +100,17 @@ private void load(OsuConfigManager config)
98100
playbackSettings.UserPlaybackRate.BindTo(master.UserPlaybackRate);
99101

100102
HUDOverlay.PlayerSettingsOverlay.AddAtStart(playbackSettings);
103+
HUDOverlay.Add(failIndicator = new ReplayFailIndicator(GameplayClockContainer)
104+
{
105+
GoToResults = () =>
106+
{
107+
if (!this.IsCurrentScreen())
108+
return;
109+
110+
ValidForResume = false;
111+
this.Push(new SoloResultsScreen(Score.ScoreInfo));
112+
}
113+
});
101114
}
102115

103116
protected override void PrepareReplay()
@@ -177,7 +190,15 @@ public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
177190

178191
protected override void PerformFail()
179192
{
180-
// base logic intentionally suppressed
193+
// base logic intentionally suppressed - we have our own custom fail interaction
194+
failIndicator.Display();
195+
}
196+
197+
public override bool OnExiting(ScreenExitEvent e)
198+
{
199+
// safety against filters or samples from the indicator playing long after the screen is exited
200+
failIndicator.RemoveAndDisposeImmediately();
201+
return base.OnExiting(e);
181202
}
182203
}
183204
}

0 commit comments

Comments
 (0)