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

Merge pull request #13894 from peppy/fix-beat-synced-container-alt

Fix multiple issues with `BeatSyncedContainer`
This commit is contained in:
Dan Balasescu 2021-07-19 23:19:20 +09:00 committed by GitHub
commit 6e104fe084
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 214 additions and 64 deletions

View File

@ -5,18 +5,19 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track; using osu.Framework.Audio.Track;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
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.Shapes;
using osu.Framework.Timing; using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Overlays; using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Play;
using osuTK.Graphics; using osuTK.Graphics;
namespace osu.Game.Tests.Visual.UserInterface namespace osu.Game.Tests.Visual.UserInterface
@ -24,37 +25,125 @@ namespace osu.Game.Tests.Visual.UserInterface
[TestFixture] [TestFixture]
public class TestSceneBeatSyncedContainer : OsuTestScene public class TestSceneBeatSyncedContainer : OsuTestScene
{ {
private readonly NowPlayingOverlay np; private TestBeatSyncedContainer beatContainer;
public TestSceneBeatSyncedContainer() private MasterGameplayClockContainer gameplayClockContainer;
{
Clock = new FramedClock();
Clock.ProcessFrame();
AddRange(new Drawable[] [SetUpSteps]
public void SetUpSteps()
{ {
new BeatContainer AddStep("Set beatmap", () =>
{
Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo);
});
AddStep("Create beat sync container", () =>
{
Children = new Drawable[]
{
gameplayClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0)
{
Child = beatContainer = new TestBeatSyncedContainer
{ {
Anchor = Anchor.BottomCentre, Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre, Origin = Anchor.BottomCentre,
}, },
np = new NowPlayingOverlay
{
Origin = Anchor.TopRight,
Anchor = Anchor.TopRight,
} }
};
}); });
AddStep("Start playback", () => gameplayClockContainer.Start());
} }
protected override void LoadComplete() [TestCase(false)]
[TestCase(true)]
public void TestDisallowMistimedEventFiring(bool allowMistimed)
{ {
base.LoadComplete(); int? lastBeatIndex = null;
np.ToggleVisibility(); double? lastActuationTime = null;
TimingControlPoint lastTimingPoint = null;
AddStep($"set mistimed to {(allowMistimed ? "allowed" : "disallowed")}", () => beatContainer.AllowMistimedEventFiring = allowMistimed);
AddStep("Set time before zero", () =>
{
beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) =>
{
lastActuationTime = gameplayClockContainer.CurrentTime;
lastTimingPoint = timingControlPoint;
lastBeatIndex = i;
beatContainer.NewBeat = null;
};
gameplayClockContainer.Seek(-1000);
});
AddUntilStep("wait for trigger", () => lastBeatIndex != null);
if (!allowMistimed)
{
AddAssert("trigger is near beat length", () => lastActuationTime != null && lastBeatIndex != null && Precision.AlmostEquals(lastTimingPoint.Time + lastBeatIndex.Value * lastTimingPoint.BeatLength, lastActuationTime.Value, BeatSyncedContainer.MISTIMED_ALLOWANCE));
}
else
{
AddAssert("trigger is not near beat length", () => lastActuationTime != null && lastBeatIndex != null && !Precision.AlmostEquals(lastTimingPoint.Time + lastBeatIndex.Value * lastTimingPoint.BeatLength, lastActuationTime.Value, BeatSyncedContainer.MISTIMED_ALLOWANCE));
}
} }
private class BeatContainer : BeatSyncedContainer [Test]
public void TestNegativeBeatsStillUsingBeatmapTiming()
{ {
private const int flash_layer_heigth = 150; int? lastBeatIndex = null;
double? lastBpm = null;
AddStep("Set time before zero", () =>
{
beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) =>
{
lastBeatIndex = i;
lastBpm = timingControlPoint.BPM;
};
gameplayClockContainer.Seek(-1000);
});
AddUntilStep("wait for trigger", () => lastBpm != null);
AddAssert("bpm is from beatmap", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 128));
AddAssert("beat index is less than zero", () => lastBeatIndex < 0);
}
[Test]
public void TestIdleBeatOnPausedClock()
{
double? lastBpm = null;
AddStep("bind event", () =>
{
beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => lastBpm = timingControlPoint.BPM;
});
AddUntilStep("wait for trigger", () => lastBpm != null);
AddAssert("bpm is from beatmap", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 128));
AddStep("pause gameplay clock", () =>
{
lastBpm = null;
gameplayClockContainer.Stop();
});
AddUntilStep("wait for trigger", () => lastBpm != null);
AddAssert("bpm is default", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 60));
}
private class TestBeatSyncedContainer : BeatSyncedContainer
{
private const int flash_layer_height = 150;
public new bool AllowMistimedEventFiring
{
get => base.AllowMistimedEventFiring;
set => base.AllowMistimedEventFiring = value;
}
private readonly InfoString timingPointCount; private readonly InfoString timingPointCount;
private readonly InfoString currentTimingPoint; private readonly InfoString currentTimingPoint;
@ -64,13 +153,11 @@ namespace osu.Game.Tests.Visual.UserInterface
private readonly InfoString adjustedBeatLength; private readonly InfoString adjustedBeatLength;
private readonly InfoString timeUntilNextBeat; private readonly InfoString timeUntilNextBeat;
private readonly InfoString timeSinceLastBeat; private readonly InfoString timeSinceLastBeat;
private readonly InfoString currentTime;
private readonly Box flashLayer; private readonly Box flashLayer;
[Resolved] public TestBeatSyncedContainer()
private MusicController musicController { get; set; }
public BeatContainer()
{ {
RelativeSizeAxes = Axes.X; RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y; AutoSizeAxes = Axes.Y;
@ -82,7 +169,7 @@ namespace osu.Game.Tests.Visual.UserInterface
Anchor = Anchor.BottomLeft, Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft, Origin = Anchor.BottomLeft,
AutoSizeAxes = Axes.Both, AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Bottom = flash_layer_heigth }, Margin = new MarginPadding { Bottom = flash_layer_height },
Children = new Drawable[] Children = new Drawable[]
{ {
new Box new Box
@ -98,6 +185,7 @@ namespace osu.Game.Tests.Visual.UserInterface
Direction = FillDirection.Vertical, Direction = FillDirection.Vertical,
Children = new Drawable[] Children = new Drawable[]
{ {
currentTime = new InfoString(@"Current time"),
timingPointCount = new InfoString(@"Timing points amount"), timingPointCount = new InfoString(@"Timing points amount"),
currentTimingPoint = new InfoString(@"Current timing point"), currentTimingPoint = new InfoString(@"Current timing point"),
beatCount = new InfoString(@"Beats amount (in the current timing point)"), beatCount = new InfoString(@"Beats amount (in the current timing point)"),
@ -116,7 +204,7 @@ namespace osu.Game.Tests.Visual.UserInterface
Anchor = Anchor.BottomCentre, Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre, Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Height = flash_layer_heigth, Height = flash_layer_height,
Children = new Drawable[] Children = new Drawable[]
{ {
new Box new Box
@ -133,8 +221,13 @@ namespace osu.Game.Tests.Visual.UserInterface
} }
} }
}; };
}
Beatmap.ValueChanged += delegate protected override void LoadComplete()
{
base.LoadComplete();
Beatmap.BindValueChanged(_ =>
{ {
timingPointCount.Value = 0; timingPointCount.Value = 0;
currentTimingPoint.Value = 0; currentTimingPoint.Value = 0;
@ -144,7 +237,7 @@ namespace osu.Game.Tests.Visual.UserInterface
adjustedBeatLength.Value = 0; adjustedBeatLength.Value = 0;
timeUntilNextBeat.Value = 0; timeUntilNextBeat.Value = 0;
timeSinceLastBeat.Value = 0; timeSinceLastBeat.Value = 0;
}; }, true);
} }
private List<TimingControlPoint> timingPoints => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ToList(); private List<TimingControlPoint> timingPoints => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ToList();
@ -164,7 +257,7 @@ namespace osu.Game.Tests.Visual.UserInterface
if (timingPoints.Count == 0) return 0; if (timingPoints.Count == 0) return 0;
if (timingPoints[^1] == current) if (timingPoints[^1] == current)
return (int)Math.Ceiling((musicController.CurrentTrack.Length - current.Time) / current.BeatLength); return (int)Math.Ceiling((BeatSyncClock.CurrentTime - current.Time) / current.BeatLength);
return (int)Math.Ceiling((getNextTimingPoint(current).Time - current.Time) / current.BeatLength); return (int)Math.Ceiling((getNextTimingPoint(current).Time - current.Time) / current.BeatLength);
} }
@ -174,8 +267,11 @@ namespace osu.Game.Tests.Visual.UserInterface
base.Update(); base.Update();
timeUntilNextBeat.Value = TimeUntilNextBeat; timeUntilNextBeat.Value = TimeUntilNextBeat;
timeSinceLastBeat.Value = TimeSinceLastBeat; timeSinceLastBeat.Value = TimeSinceLastBeat;
currentTime.Value = BeatSyncClock.CurrentTime;
} }
public Action<int, TimingControlPoint, EffectControlPoint, ChannelAmplitudes> NewBeat;
protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes)
{ {
base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes);
@ -187,7 +283,9 @@ namespace osu.Game.Tests.Visual.UserInterface
beatsPerMinute.Value = 60000 / timingPoint.BeatLength; beatsPerMinute.Value = 60000 / timingPoint.BeatLength;
adjustedBeatLength.Value = timingPoint.BeatLength; adjustedBeatLength.Value = timingPoint.BeatLength;
flashLayer.FadeOutFromOne(timingPoint.BeatLength); flashLayer.FadeOutFromOne(timingPoint.BeatLength / 4);
NewBeat?.Invoke(beatIndex, timingPoint, effectPoint, amplitudes);
} }
} }
@ -200,7 +298,7 @@ namespace osu.Game.Tests.Visual.UserInterface
public double Value public double Value
{ {
set => valueText.Text = $"{value:G}"; set => valueText.Text = $"{value:0.##}";
} }
public InfoString(string header) public InfoString(string header)

View File

@ -1,19 +1,32 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio.Track; using osu.Framework.Audio.Track;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Timing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Screens.Play;
namespace osu.Game.Graphics.Containers namespace osu.Game.Graphics.Containers
{ {
/// <summary>
/// A container which fires a callback when a new beat is reached.
/// Consumes a parent <see cref="GameplayClock"/> or <see cref="Beatmap"/> (whichever is first available).
/// </summary>
/// <remarks>
/// This container does not set its own clock to the source used for beat matching.
/// This means that if the beat source clock is playing faster or slower, animations may unexpectedly overlap.
/// Make sure this container's Clock is also set to the expected source (or within a parent element which provides this).
///
/// This container will also trigger beat events when the beat matching clock is paused at <see cref="TimingControlPoint.DEFAULT"/>'s BPM.
/// </remarks>
public class BeatSyncedContainer : Container public class BeatSyncedContainer : Container
{ {
protected readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>();
private int lastBeat; private int lastBeat;
private TimingControlPoint lastTimingPoint; private TimingControlPoint lastTimingPoint;
@ -23,6 +36,19 @@ namespace osu.Game.Graphics.Containers
/// </summary> /// </summary>
protected double EarlyActivationMilliseconds; protected double EarlyActivationMilliseconds;
/// <summary>
/// While this container automatically applied an animation delay (meaning any animations inside a <see cref="OnNewBeat"/> implementation will
/// always be correctly timed), the event itself can potentially fire away from the related beat.
///
/// By setting this to false, cases where the event is to be fired more than <see cref="MISTIMED_ALLOWANCE"/> from the related beat will be skipped.
/// </summary>
protected bool AllowMistimedEventFiring = true;
/// <summary>
/// The maximum deviance from the actual beat that an <see cref="OnNewBeat"/> can fire when <see cref="AllowMistimedEventFiring"/> is set to false.
/// </summary>
public const double MISTIMED_ALLOWANCE = 16;
/// <summary> /// <summary>
/// The time in milliseconds until the next beat. /// The time in milliseconds until the next beat.
/// </summary> /// </summary>
@ -43,16 +69,49 @@ namespace osu.Game.Graphics.Containers
/// </summary> /// </summary>
public double MinimumBeatLength { get; set; } public double MinimumBeatLength { get; set; }
/// <summary>
/// Whether this container is currently tracking a beatmap's timing data.
/// </summary>
protected bool IsBeatSyncedWithTrack { get; private set; } protected bool IsBeatSyncedWithTrack { get; private set; }
protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes)
{
}
[Resolved]
protected IBindable<WorkingBeatmap> Beatmap { get; private set; }
[Resolved(canBeNull: true)]
protected GameplayClock GameplayClock { get; private set; }
protected IClock BeatSyncClock
{
get
{
if (GameplayClock != null)
return GameplayClock;
if (Beatmap.Value.TrackLoaded)
return Beatmap.Value.Track;
return null;
}
}
protected override void Update() protected override void Update()
{ {
ITrack track = null; ITrack track = null;
IBeatmap beatmap = null; IBeatmap beatmap = null;
double currentTrackTime = 0; TimingControlPoint timingPoint;
TimingControlPoint timingPoint = null; EffectControlPoint effectPoint;
EffectControlPoint effectPoint = null;
IClock clock = BeatSyncClock;
if (clock == null)
return;
double currentTrackTime = clock.CurrentTime;
if (Beatmap.Value.TrackLoaded && Beatmap.Value.BeatmapLoaded) if (Beatmap.Value.TrackLoaded && Beatmap.Value.BeatmapLoaded)
{ {
@ -60,23 +119,26 @@ namespace osu.Game.Graphics.Containers
beatmap = Beatmap.Value.Beatmap; beatmap = Beatmap.Value.Beatmap;
} }
if (track != null && beatmap != null && track.IsRunning && track.Length > 0) IsBeatSyncedWithTrack = beatmap != null && clock.IsRunning && track?.Length > 0;
if (IsBeatSyncedWithTrack)
{ {
currentTrackTime = track.CurrentTime + EarlyActivationMilliseconds; Debug.Assert(beatmap != null);
timingPoint = beatmap.ControlPointInfo.TimingPointAt(currentTrackTime); timingPoint = beatmap.ControlPointInfo.TimingPointAt(currentTrackTime);
effectPoint = beatmap.ControlPointInfo.EffectPointAt(currentTrackTime); effectPoint = beatmap.ControlPointInfo.EffectPointAt(currentTrackTime);
} }
else
IsBeatSyncedWithTrack = timingPoint?.BeatLength > 0;
if (timingPoint == null || !IsBeatSyncedWithTrack)
{ {
// this may be the case where the beat syncing clock has been paused.
// we still want to show an idle animation, so use this container's time instead.
currentTrackTime = Clock.CurrentTime; currentTrackTime = Clock.CurrentTime;
timingPoint = TimingControlPoint.DEFAULT; timingPoint = TimingControlPoint.DEFAULT;
effectPoint = EffectControlPoint.DEFAULT; effectPoint = EffectControlPoint.DEFAULT;
} }
currentTrackTime += EarlyActivationMilliseconds;
double beatLength = timingPoint.BeatLength / Divisor; double beatLength = timingPoint.BeatLength / Divisor;
while (beatLength < MinimumBeatLength) while (beatLength < MinimumBeatLength)
@ -89,7 +151,7 @@ namespace osu.Game.Graphics.Containers
beatIndex--; beatIndex--;
TimeUntilNextBeat = (timingPoint.Time - currentTrackTime) % beatLength; TimeUntilNextBeat = (timingPoint.Time - currentTrackTime) % beatLength;
if (TimeUntilNextBeat < 0) if (TimeUntilNextBeat <= 0)
TimeUntilNextBeat += beatLength; TimeUntilNextBeat += beatLength;
TimeSinceLastBeat = beatLength - TimeUntilNextBeat; TimeSinceLastBeat = beatLength - TimeUntilNextBeat;
@ -97,21 +159,16 @@ namespace osu.Game.Graphics.Containers
if (timingPoint == lastTimingPoint && beatIndex == lastBeat) if (timingPoint == lastTimingPoint && beatIndex == lastBeat)
return; return;
// as this event is sometimes used for sound triggers where `BeginDelayedSequence` has no effect, avoid firing it if too far away from the beat.
// this can happen after a seek operation.
if (AllowMistimedEventFiring || Math.Abs(TimeSinceLastBeat) < MISTIMED_ALLOWANCE)
{
using (BeginDelayedSequence(-TimeSinceLastBeat)) using (BeginDelayedSequence(-TimeSinceLastBeat))
OnNewBeat(beatIndex, timingPoint, effectPoint, track?.CurrentAmplitudes ?? ChannelAmplitudes.Empty); OnNewBeat(beatIndex, timingPoint, effectPoint, track?.CurrentAmplitudes ?? ChannelAmplitudes.Empty);
}
lastBeat = beatIndex; lastBeat = beatIndex;
lastTimingPoint = timingPoint; lastTimingPoint = timingPoint;
} }
[BackgroundDependencyLoader]
private void load(IBindable<WorkingBeatmap> beatmap)
{
Beatmap.BindTo(beatmap);
}
protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes)
{
}
} }
} }

View File

@ -72,8 +72,6 @@ namespace osu.Game.Screens.Menu
set => colourAndTriangles.FadeTo(value ? 1 : 0, transition_length, Easing.OutQuint); set => colourAndTriangles.FadeTo(value ? 1 : 0, transition_length, Easing.OutQuint);
} }
public bool BeatMatching = true;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => logoContainer.ReceivePositionalInputAt(screenSpacePos); public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => logoContainer.ReceivePositionalInputAt(screenSpacePos);
public bool Ripple public bool Ripple
@ -272,8 +270,6 @@ namespace osu.Game.Screens.Menu
{ {
base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes);
if (!BeatMatching) return;
lastBeatIndex = beatIndex; lastBeatIndex = beatIndex;
var beatLength = timingPoint.BeatLength; var beatLength = timingPoint.BeatLength;

View File

@ -242,7 +242,6 @@ namespace osu.Game.Screens
logo.Anchor = Anchor.TopLeft; logo.Anchor = Anchor.TopLeft;
logo.Origin = Anchor.Centre; logo.Origin = Anchor.Centre;
logo.RelativePositionAxes = Axes.Both; logo.RelativePositionAxes = Axes.Both;
logo.BeatMatching = true;
logo.Triangles = true; logo.Triangles = true;
logo.Ripple = true; logo.Ripple = true;
} }