1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-16 00:02:54 +08:00

Merge pull request #12432 from marlinabowring/play-storyboard-outro

Add support for playing storyboards beyond gameplay end time
This commit is contained in:
Dean Herbert 2021-05-05 22:21:25 +09:00 committed by GitHub
commit ed1ae4775f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 335 additions and 22 deletions

View File

@ -0,0 +1,191 @@
// 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 System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Screens.Ranking;
using osu.Game.Storyboards;
using osuTK;
namespace osu.Game.Tests.Visual.Gameplay
{
public class TestSceneStoryboardWithOutro : PlayerTestScene
{
protected override bool HasCustomSteps => true;
protected new OutroPlayer Player => (OutroPlayer)base.Player;
private double currentStoryboardDuration;
private bool showResults = true;
private event Func<HealthProcessor, JudgementResult, bool> currentFailConditions;
[SetUpSteps]
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("enable storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, true));
AddStep("set dim level to 0", () => LocalConfig.SetValue<double>(OsuSetting.DimLevel, 0));
AddStep("reset fail conditions", () => currentFailConditions = (_, __) => false);
AddStep("set storyboard duration to 2s", () => currentStoryboardDuration = 2000);
AddStep("set ShowResults = true", () => showResults = true);
}
[Test]
public void TestStoryboardSkipOutro()
{
CreateTest(null);
AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value);
AddStep("skip outro", () => InputManager.Key(osuTK.Input.Key.Space));
AddAssert("score shown", () => Player.IsScoreShown);
}
[Test]
public void TestStoryboardNoSkipOutro()
{
CreateTest(null);
AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
AddUntilStep("wait for score shown", () => Player.IsScoreShown);
}
[Test]
public void TestStoryboardExitToSkipOutro()
{
CreateTest(null);
AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value);
AddStep("exit via pause", () => Player.ExitViaPause());
AddAssert("score shown", () => Player.IsScoreShown);
}
[TestCase(false)]
[TestCase(true)]
public void TestStoryboardToggle(bool enabledAtBeginning)
{
CreateTest(null);
AddStep($"{(enabledAtBeginning ? "enable" : "disable")} storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, enabledAtBeginning));
AddStep("toggle storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, !enabledAtBeginning));
AddUntilStep("wait for score shown", () => Player.IsScoreShown);
}
[Test]
public void TestOutroEndsDuringFailAnimation()
{
CreateTest(() =>
{
AddStep("fail on first judgement", () => currentFailConditions = (_, __) => true);
AddStep("set storyboard duration to 1.3s", () => currentStoryboardDuration = 1300);
});
AddUntilStep("wait for fail", () => Player.HasFailed);
AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible);
}
[Test]
public void TestShowResultsFalse()
{
CreateTest(() =>
{
AddStep("set ShowResults = false", () => showResults = false);
});
AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
AddWaitStep("wait", 10);
AddAssert("no score shown", () => !Player.IsScoreShown);
}
[Test]
public void TestStoryboardEndsBeforeCompletion()
{
CreateTest(() => AddStep("set storyboard duration to .1s", () => currentStoryboardDuration = 100));
AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value);
AddUntilStep("wait for score shown", () => Player.IsScoreShown);
}
[Test]
public void TestStoryboardRewind()
{
SkipOverlay.FadeContainer fadeContainer() => Player.ChildrenOfType<SkipOverlay.FadeContainer>().First();
CreateTest(null);
AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value);
AddUntilStep("skip overlay content becomes visible", () => fadeContainer().State == Visibility.Visible);
AddStep("rewind", () => Player.GameplayClockContainer.Seek(-1000));
AddUntilStep("skip overlay content not visible", () => fadeContainer().State == Visibility.Hidden);
AddUntilStep("skip overlay content becomes visible", () => fadeContainer().State == Visibility.Visible);
AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration);
}
protected override bool AllowFail => true;
protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
protected override TestPlayer CreatePlayer(Ruleset ruleset) => new OutroPlayer(currentFailConditions, showResults);
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset)
{
var beatmap = new Beatmap();
beatmap.HitObjects.Add(new HitCircle());
return beatmap;
}
protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null)
{
return base.CreateWorkingBeatmap(beatmap, createStoryboard(currentStoryboardDuration));
}
private Storyboard createStoryboard(double duration)
{
var storyboard = new Storyboard();
var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero);
sprite.TimelineGroup.Alpha.Add(Easing.None, 0, duration, 1, 0);
storyboard.GetLayer("Background").Add(sprite);
return storyboard;
}
protected class OutroPlayer : TestPlayer
{
public void ExitViaPause() => PerformExit(true);
public new FailOverlay FailOverlay => base.FailOverlay;
public bool IsScoreShown => !this.IsCurrentScreen() && this.GetChildScreen() is ResultsScreen;
private event Func<HealthProcessor, JudgementResult, bool> failConditions;
public OutroPlayer(Func<HealthProcessor, JudgementResult, bool> failConditions, bool showResults = true)
: base(false, showResults)
{
this.failConditions = failConditions;
}
protected override void LoadComplete()
{
base.LoadComplete();
HealthProcessor.FailConditions += failConditions;
}
protected override Task ImportScore(Score score)
{
return Task.CompletedTask;
}
}
}
}

View File

@ -48,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{ {
AllowPause = false, AllowPause = false,
AllowRestart = false, AllowRestart = false,
AllowSkippingIntro = false, AllowSkipping = false,
}) })
{ {
this.userIds = userIds; this.userIds = userIds;

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Storyboards; using osu.Game.Storyboards;
@ -19,6 +20,14 @@ namespace osu.Game.Screens.Play
private readonly Storyboard storyboard; private readonly Storyboard storyboard;
private DrawableStoryboard drawableStoryboard; private DrawableStoryboard drawableStoryboard;
/// <summary>
/// Whether the storyboard is considered finished.
/// </summary>
/// <remarks>
/// This is true by default in here, until an actual drawable storyboard is loaded, in which case it'll bind to it.
/// </remarks>
public IBindable<bool> HasStoryboardEnded = new BindableBool(true);
public DimmableStoryboard(Storyboard storyboard) public DimmableStoryboard(Storyboard storyboard)
{ {
this.storyboard = storyboard; this.storyboard = storyboard;
@ -49,6 +58,7 @@ namespace osu.Game.Screens.Play
return; return;
drawableStoryboard = storyboard.CreateDrawable(); drawableStoryboard = storyboard.CreateDrawable();
HasStoryboardEnded.BindTo(drawableStoryboard.HasStoryboardEnded);
if (async) if (async)
LoadComponentAsync(drawableStoryboard, onStoryboardCreated); LoadComponentAsync(drawableStoryboard, onStoryboardCreated);

View File

@ -104,7 +104,8 @@ namespace osu.Game.Screens.Play
private BreakTracker breakTracker; private BreakTracker breakTracker;
private SkipOverlay skipOverlay; private SkipOverlay skipIntroOverlay;
private SkipOverlay skipOutroOverlay;
protected ScoreProcessor ScoreProcessor { get; private set; } protected ScoreProcessor ScoreProcessor { get; private set; }
@ -244,7 +245,6 @@ namespace osu.Game.Screens.Play
HUDOverlay.ShowHud.Value = false; HUDOverlay.ShowHud.Value = false;
HUDOverlay.ShowHud.Disabled = true; HUDOverlay.ShowHud.Disabled = true;
BreakOverlay.Hide(); BreakOverlay.Hide();
skipOverlay.Hide();
} }
DrawableRuleset.FrameStableClock.WaitingOnFrames.BindValueChanged(waiting => DrawableRuleset.FrameStableClock.WaitingOnFrames.BindValueChanged(waiting =>
@ -281,8 +281,14 @@ namespace osu.Game.Screens.Play
ScoreProcessor.RevertResult(r); ScoreProcessor.RevertResult(r);
}; };
DimmableStoryboard.HasStoryboardEnded.ValueChanged += storyboardEnded =>
{
if (storyboardEnded.NewValue && completionProgressDelegate == null)
updateCompletionState();
};
// Bind the judgement processors to ourselves // Bind the judgement processors to ourselves
ScoreProcessor.HasCompleted.ValueChanged += updateCompletionState; ScoreProcessor.HasCompleted.BindValueChanged(_ => updateCompletionState());
HealthProcessor.Failed += onFail; HealthProcessor.Failed += onFail;
foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>()) foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>())
@ -355,10 +361,15 @@ namespace osu.Game.Screens.Play
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre Origin = Anchor.Centre
}, },
skipOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) skipIntroOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime)
{ {
RequestSkip = performUserRequestedSkip RequestSkip = performUserRequestedSkip
}, },
skipOutroOverlay = new SkipOverlay(Beatmap.Value.Storyboard.LatestEventTime ?? 0)
{
RequestSkip = () => updateCompletionState(true),
Alpha = 0
},
FailOverlay = new FailOverlay FailOverlay = new FailOverlay
{ {
OnRetry = Restart, OnRetry = Restart,
@ -385,12 +396,15 @@ namespace osu.Game.Screens.Play
} }
}; };
if (!Configuration.AllowSkipping || !DrawableRuleset.AllowGameplayOverlays)
{
skipIntroOverlay.Expire();
skipOutroOverlay.Expire();
}
if (GameplayClockContainer is MasterGameplayClockContainer master) if (GameplayClockContainer is MasterGameplayClockContainer master)
HUDOverlay.PlayerSettingsOverlay.PlaybackSettings.UserPlaybackRate.BindTarget = master.UserPlaybackRate; HUDOverlay.PlayerSettingsOverlay.PlaybackSettings.UserPlaybackRate.BindTarget = master.UserPlaybackRate;
if (!Configuration.AllowSkippingIntro)
skipOverlay.Expire();
if (Configuration.AllowRestart) if (Configuration.AllowRestart)
{ {
container.Add(new HotkeyRetryOverlay container.Add(new HotkeyRetryOverlay
@ -525,6 +539,10 @@ namespace osu.Game.Screens.Play
Pause(); Pause();
return; return;
} }
// if the score is ready for display but results screen has not been pushed yet (e.g. storyboard is still playing beyond gameplay), then transition to results screen instead of exiting.
if (prepareScoreForDisplayTask != null)
updateCompletionState(true);
} }
this.Exit(); this.Exit();
@ -564,17 +582,23 @@ namespace osu.Game.Screens.Play
private ScheduledDelegate completionProgressDelegate; private ScheduledDelegate completionProgressDelegate;
private Task<ScoreInfo> prepareScoreForDisplayTask; private Task<ScoreInfo> prepareScoreForDisplayTask;
private void updateCompletionState(ValueChangedEvent<bool> completionState) /// <summary>
/// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime.
/// </summary>
/// <param name="skipStoryboardOutro">If in a state where a storyboard outro is to be played, offers the choice of skipping beyond it.</param>
/// <exception cref="InvalidOperationException">Thrown if this method is called more than once without changing state.</exception>
private void updateCompletionState(bool skipStoryboardOutro = false)
{ {
// screen may be in the exiting transition phase. // screen may be in the exiting transition phase.
if (!this.IsCurrentScreen()) if (!this.IsCurrentScreen())
return; return;
if (!completionState.NewValue) if (!ScoreProcessor.HasCompleted.Value)
{ {
completionProgressDelegate?.Cancel(); completionProgressDelegate?.Cancel();
completionProgressDelegate = null; completionProgressDelegate = null;
ValidForResume = true; ValidForResume = true;
skipOutroOverlay.Hide();
return; return;
} }
@ -614,6 +638,20 @@ namespace osu.Game.Screens.Play
return score.ScoreInfo; return score.ScoreInfo;
}); });
if (skipStoryboardOutro)
{
scheduleCompletion();
return;
}
bool storyboardHasOutro = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value;
if (storyboardHasOutro)
{
skipOutroOverlay.Show();
return;
}
using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY)) using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY))
scheduleCompletion(); scheduleCompletion();
} }

View File

@ -21,8 +21,8 @@ namespace osu.Game.Screens.Play
public bool AllowRestart { get; set; } = true; public bool AllowRestart { get; set; } = true;
/// <summary> /// <summary>
/// Whether the player should be allowed to skip the intro, advancing to the start of gameplay. /// Whether the player should be allowed to skip intros/outros, advancing to the start of gameplay or the end of a storyboard.
/// </summary> /// </summary>
public bool AllowSkippingIntro { get; set; } = true; public bool AllowSkipping { get; set; } = true;
} }
} }

View File

@ -8,19 +8,19 @@ using osu.Framework.Audio;
using osu.Framework.Audio.Sample; using osu.Framework.Audio.Sample;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Input.Bindings;
using osu.Game.Screens.Ranking; using osu.Game.Screens.Ranking;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics.Containers;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Input.Bindings;
namespace osu.Game.Screens.Play namespace osu.Game.Screens.Play
{ {
@ -92,6 +92,18 @@ namespace osu.Game.Screens.Play
private double fadeOutBeginTime => startTime - MasterGameplayClockContainer.MINIMUM_SKIP_TIME; private double fadeOutBeginTime => startTime - MasterGameplayClockContainer.MINIMUM_SKIP_TIME;
public override void Hide()
{
base.Hide();
fadeContainer.Hide();
}
public override void Show()
{
base.Show();
fadeContainer.Show();
}
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
@ -147,7 +159,7 @@ namespace osu.Game.Screens.Play
{ {
} }
private class FadeContainer : Container, IStateful<Visibility> public class FadeContainer : Container, IStateful<Visibility>
{ {
public event Action<Visibility> StateChanged; public event Action<Visibility> StateChanged;
@ -170,7 +182,7 @@ namespace osu.Game.Screens.Play
switch (state) switch (state)
{ {
case Visibility.Visible: case Visibility.Visible:
// we may be triggered to become visible mnultiple times but we only want to transform once. // we may be triggered to become visible multiple times but we only want to transform once.
if (stateChanged) if (stateChanged)
this.FadeIn(500, Easing.OutExpo); this.FadeIn(500, Easing.OutExpo);

View File

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using osuTK; using osuTK;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
@ -19,6 +20,13 @@ namespace osu.Game.Storyboards.Drawables
[Cached] [Cached]
public Storyboard Storyboard { get; } public Storyboard Storyboard { get; }
/// <summary>
/// Whether the storyboard is considered finished.
/// </summary>
public IBindable<bool> HasStoryboardEnded => hasStoryboardEnded;
private readonly BindableBool hasStoryboardEnded = new BindableBool();
protected override Container<DrawableStoryboardLayer> Content { get; } protected override Container<DrawableStoryboardLayer> Content { get; }
protected override Vector2 DrawScale => new Vector2(Parent.DrawHeight / 480); protected override Vector2 DrawScale => new Vector2(Parent.DrawHeight / 480);
@ -39,6 +47,8 @@ namespace osu.Game.Storyboards.Drawables
public override bool RemoveCompletedTransforms => false; public override bool RemoveCompletedTransforms => false;
private double? lastEventEndTime;
private DependencyContainer dependencies; private DependencyContainer dependencies;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
@ -73,6 +83,14 @@ namespace osu.Game.Storyboards.Drawables
Add(layer.CreateDrawable()); Add(layer.CreateDrawable());
} }
lastEventEndTime = Storyboard.LatestEventTime;
}
protected override void Update()
{
base.Update();
hasStoryboardEnded.Value = lastEventEndTime == null || Time.Current >= lastEventEndTime;
} }
public DrawableStoryboardLayer OverlayLayer => Children.Single(layer => layer.Name == "Overlay"); public DrawableStoryboardLayer OverlayLayer => Children.Single(layer => layer.Name == "Overlay");

View File

@ -14,4 +14,17 @@ namespace osu.Game.Storyboards
Drawable CreateDrawable(); Drawable CreateDrawable();
} }
public static class StoryboardElementExtensions
{
/// <summary>
/// Returns the end time of this storyboard element.
/// </summary>
/// <remarks>
/// This returns the <see cref="IStoryboardElementWithDuration.EndTime"/> where available, falling back to <see cref="IStoryboardElement.StartTime"/> otherwise.
/// </remarks>
/// <param name="element">The storyboard element.</param>
/// <returns>The end time of this element.</returns>
public static double GetEndTime(this IStoryboardElement element) => (element as IStoryboardElementWithDuration)?.EndTime ?? element.StartTime;
}
} }

View File

@ -0,0 +1,21 @@
// 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.
namespace osu.Game.Storyboards
{
/// <summary>
/// A <see cref="IStoryboardElement"/> that ends at a different time than its start time.
/// </summary>
public interface IStoryboardElementWithDuration : IStoryboardElement
{
/// <summary>
/// The time at which the <see cref="IStoryboardElement"/> ends.
/// </summary>
double EndTime { get; }
/// <summary>
/// The duration of the StoryboardElement.
/// </summary>
double Duration => EndTime - StartTime;
}
}

View File

@ -36,6 +36,16 @@ namespace osu.Game.Storyboards
/// </remarks> /// </remarks>
public double? EarliestEventTime => Layers.SelectMany(l => l.Elements).OrderBy(e => e.StartTime).FirstOrDefault()?.StartTime; public double? EarliestEventTime => Layers.SelectMany(l => l.Elements).OrderBy(e => e.StartTime).FirstOrDefault()?.StartTime;
/// <summary>
/// Across all layers, find the latest point in time that a storyboard element ends at.
/// Will return null if there are no elements.
/// </summary>
/// <remarks>
/// This iterates all elements and as such should be used sparingly or stored locally.
/// Videos and samples return StartTime as their EndTIme.
/// </remarks>
public double? LatestEventTime => Layers.SelectMany(l => l.Elements).OrderBy(e => e.GetEndTime()).LastOrDefault()?.GetEndTime();
/// <summary> /// <summary>
/// Depth of the currently front-most storyboard layer, excluding the overlay layer. /// Depth of the currently front-most storyboard layer, excluding the overlay layer.
/// </summary> /// </summary>

View File

@ -11,7 +11,7 @@ using JetBrains.Annotations;
namespace osu.Game.Storyboards namespace osu.Game.Storyboards
{ {
public class StoryboardSprite : IStoryboardElement public class StoryboardSprite : IStoryboardElementWithDuration
{ {
private readonly List<CommandLoop> loops = new List<CommandLoop>(); private readonly List<CommandLoop> loops = new List<CommandLoop>();
private readonly List<CommandTrigger> triggers = new List<CommandTrigger>(); private readonly List<CommandTrigger> triggers = new List<CommandTrigger>();