diff --git a/osu.Android.props b/osu.Android.props
index 17a6178641..bff3627af7 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,7 +52,7 @@
-
+
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs
index b68231ce64..cdb2a7fe77 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs
@@ -77,6 +77,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
return sliderCreatedFor(args);
});
+ AddAssert("samples exist", sliderSampleExist);
+
AddStep("undo", () => Editor.Undo());
AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && slider is not null && objectsRestored(circle1, slider, circle2));
}
@@ -122,6 +124,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
return sliderCreatedFor(args);
});
+ AddAssert("samples exist", sliderSampleExist);
+
AddAssert("merged slider matches first slider", () =>
{
var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
@@ -165,6 +169,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
(pos: circle1.Position, pathType: PathType.Linear),
(pos: circle2.Position, pathType: null)));
+ AddAssert("samples exist", sliderSampleExist);
+
AddAssert("spinner not merged", () => EditorBeatmap.HitObjects.Contains(spinner));
}
@@ -209,5 +215,15 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
return true;
}
+
+ private bool sliderSampleExist()
+ {
+ if (EditorBeatmap.SelectedHitObjects.Count != 1)
+ return false;
+
+ var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
+
+ return mergedSlider.Samples[0] is not null;
+ }
}
}
diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
index 061c5008c5..2c5bbdb279 100644
--- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
+++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
@@ -371,6 +371,7 @@ namespace osu.Game.Rulesets.Osu.Edit
Position = firstHitObject.Position,
NewCombo = firstHitObject.NewCombo,
SampleControlPoint = firstHitObject.SampleControlPoint,
+ Samples = firstHitObject.Samples,
};
if (mergedHitObject.Path.ControlPoints.Count == 0)
diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs
index 18ae2cb7c8..577933eae3 100644
--- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs
+++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs
@@ -84,12 +84,15 @@ namespace osu.Game.Tests.Gameplay
});
});
- AddStep("reset clock", () => gameplayContainer.Start());
+ AddStep("reset clock", () => gameplayContainer.Reset(startClock: true));
AddUntilStep("sample played", () => sample.RequestedPlaying);
AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue);
}
+ ///
+ /// Sample at 0ms, start time at 1000ms (so the sample should not be played).
+ ///
[Test]
public void TestSampleHasLifetimeEndWithInitialClockTime()
{
@@ -104,12 +107,13 @@ namespace osu.Game.Tests.Gameplay
Add(gameplayContainer = new MasterGameplayClockContainer(working, start_time)
{
- StartTime = start_time,
Child = new FrameStabilityContainer
{
Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1))
}
});
+
+ gameplayContainer.Reset(start_time);
});
AddStep("start time", () => gameplayContainer.Start());
@@ -143,7 +147,7 @@ namespace osu.Game.Tests.Gameplay
});
});
- AddStep("start", () => gameplayContainer.Start());
+ AddStep("reset clock", () => gameplayContainer.Reset(startClock: true));
AddUntilStep("sample played", () => sample.IsPlayed);
AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue);
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs
index 25251bf1d6..c066f1417c 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs
@@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
private LeadInPlayer player = null!;
- private const double lenience_ms = 10;
+ private const double lenience_ms = 100;
private const double first_hit_object = 2170;
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
index 0aa412a4fd..929af7f84d 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
@@ -85,7 +85,7 @@ namespace osu.Game.Tests.Visual.Gameplay
sendFrames(startTime: gameplay_start);
- AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start);
+ AddAssert("time is greater than seek target", () => currentFrameStableTime, () => Is.GreaterThan(gameplay_start));
}
///
@@ -119,7 +119,7 @@ namespace osu.Game.Tests.Visual.Gameplay
waitForPlayer();
AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing);
- AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start);
+ AddAssert("time is greater than seek target", () => currentFrameStableTime, () => Is.GreaterThan(gameplay_start));
}
[Test]
@@ -147,7 +147,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame);
checkPaused(true);
- AddAssert("time advanced", () => currentFrameStableTime > pausedTime);
+ AddAssert("time advanced", () => currentFrameStableTime, () => Is.GreaterThan(pausedTime));
}
[Test]
@@ -176,7 +176,7 @@ namespace osu.Game.Tests.Visual.Gameplay
sendFrames(300);
- AddUntilStep("playing from correct point in time", () => player.ChildrenOfType().First().FrameStableClock.CurrentTime > 30000);
+ AddUntilStep("playing from correct point in time", () => player.ChildrenOfType().First().FrameStableClock.CurrentTime, () => Is.GreaterThan(30000));
}
[Test]
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
index bab613bed7..a11a67aebd 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
@@ -165,11 +165,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
sendFrames(PLAYER_1_ID, 40);
sendFrames(PLAYER_2_ID, 20);
- checkPaused(PLAYER_2_ID, true);
- checkPausedInstant(PLAYER_1_ID, false);
+ waitUntilPaused(PLAYER_2_ID);
+ checkRunningInstant(PLAYER_1_ID);
AddAssert("master clock still running", () => this.ChildrenOfType().Single().IsRunning);
- checkPaused(PLAYER_1_ID, true);
+ waitUntilPaused(PLAYER_1_ID);
AddUntilStep("master clock paused", () => !this.ChildrenOfType().Single().IsRunning);
}
@@ -181,13 +181,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send frames for one player only, both should remain paused.
sendFrames(PLAYER_1_ID, 20);
- checkPausedInstant(PLAYER_1_ID, true);
- checkPausedInstant(PLAYER_2_ID, true);
+ checkPausedInstant(PLAYER_1_ID);
+ checkPausedInstant(PLAYER_2_ID);
// Send frames for the other player, both should now start playing.
sendFrames(PLAYER_2_ID, 20);
- checkPausedInstant(PLAYER_1_ID, false);
- checkPausedInstant(PLAYER_2_ID, false);
+ checkRunningInstant(PLAYER_1_ID);
+ checkRunningInstant(PLAYER_2_ID);
}
[Test]
@@ -198,15 +198,15 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send frames for one player only, both should remain paused.
sendFrames(PLAYER_1_ID, 1000);
- checkPausedInstant(PLAYER_1_ID, true);
- checkPausedInstant(PLAYER_2_ID, true);
+ checkPausedInstant(PLAYER_1_ID);
+ checkPausedInstant(PLAYER_2_ID);
// Wait for the start delay seconds...
AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction));
// Player 1 should start playing by itself, player 2 should remain paused.
- checkPausedInstant(PLAYER_1_ID, false);
- checkPausedInstant(PLAYER_2_ID, true);
+ checkRunningInstant(PLAYER_1_ID);
+ checkPausedInstant(PLAYER_2_ID);
}
[Test]
@@ -218,26 +218,26 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send initial frames for both players. A few more for player 1.
sendFrames(PLAYER_1_ID, 20);
sendFrames(PLAYER_2_ID);
- checkPausedInstant(PLAYER_1_ID, false);
- checkPausedInstant(PLAYER_2_ID, false);
+ checkRunningInstant(PLAYER_1_ID);
+ checkRunningInstant(PLAYER_2_ID);
// Eventually player 2 will pause, player 1 must remain running.
- checkPaused(PLAYER_2_ID, true);
- checkPausedInstant(PLAYER_1_ID, false);
+ waitUntilPaused(PLAYER_2_ID);
+ checkRunningInstant(PLAYER_1_ID);
// Eventually both players will run out of frames and should pause.
- checkPaused(PLAYER_1_ID, true);
- checkPausedInstant(PLAYER_2_ID, true);
+ waitUntilPaused(PLAYER_1_ID);
+ checkPausedInstant(PLAYER_2_ID);
// Send more frames for the first player only. Player 1 should start playing with player 2 remaining paused.
sendFrames(PLAYER_1_ID, 20);
- checkPausedInstant(PLAYER_2_ID, true);
- checkPausedInstant(PLAYER_1_ID, false);
+ checkPausedInstant(PLAYER_2_ID);
+ checkRunningInstant(PLAYER_1_ID);
// Send more frames for the second player. Both should be playing
sendFrames(PLAYER_2_ID, 20);
- checkPausedInstant(PLAYER_2_ID, false);
- checkPausedInstant(PLAYER_1_ID, false);
+ checkRunningInstant(PLAYER_2_ID);
+ checkRunningInstant(PLAYER_1_ID);
}
[Test]
@@ -249,16 +249,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
// Send initial frames for both players. A few more for player 1.
sendFrames(PLAYER_1_ID, 1000);
sendFrames(PLAYER_2_ID, 30);
- checkPausedInstant(PLAYER_1_ID, false);
- checkPausedInstant(PLAYER_2_ID, false);
+ checkRunningInstant(PLAYER_1_ID);
+ checkRunningInstant(PLAYER_2_ID);
// Eventually player 2 will run out of frames and should pause.
- checkPaused(PLAYER_2_ID, true);
+ waitUntilPaused(PLAYER_2_ID);
AddWaitStep("wait a few more frames", 10);
// Send more frames for player 2. It should unpause.
sendFrames(PLAYER_2_ID, 1000);
- checkPausedInstant(PLAYER_2_ID, false);
+ checkRunningInstant(PLAYER_2_ID);
// Player 2 should catch up to player 1 after unpausing.
waitForCatchup(PLAYER_2_ID);
@@ -271,21 +271,28 @@ namespace osu.Game.Tests.Visual.Multiplayer
start(new[] { PLAYER_1_ID, PLAYER_2_ID });
loadSpectateScreen();
+ // With no frames, the synchronisation state will be TooFarAhead.
+ // In this state, all players should be muted.
assertMuted(PLAYER_1_ID, true);
assertMuted(PLAYER_2_ID, true);
- sendFrames(PLAYER_1_ID);
+ // Send frames for both players, with more frames for player 2.
+ sendFrames(PLAYER_1_ID, 5);
sendFrames(PLAYER_2_ID, 20);
- checkPaused(PLAYER_1_ID, false);
- assertOneNotMuted();
- checkPaused(PLAYER_1_ID, true);
+ // While both players are running, one of them should be un-muted.
+ waitUntilRunning(PLAYER_1_ID);
+ assertOnePlayerNotMuted();
+
+ // After player 1 runs out of frames, the un-muted player should always be player 2.
+ waitUntilPaused(PLAYER_1_ID);
+ waitUntilRunning(PLAYER_2_ID);
assertMuted(PLAYER_1_ID, true);
assertMuted(PLAYER_2_ID, false);
sendFrames(PLAYER_1_ID, 100);
waitForCatchup(PLAYER_1_ID);
- checkPaused(PLAYER_2_ID, true);
+ waitUntilPaused(PLAYER_2_ID);
assertMuted(PLAYER_1_ID, false);
assertMuted(PLAYER_2_ID, true);
@@ -319,7 +326,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
sendFrames(PLAYER_1_ID, 300);
AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction));
- checkPaused(PLAYER_1_ID, false);
+ waitUntilRunning(PLAYER_1_ID);
sendFrames(PLAYER_2_ID, 300);
AddUntilStep("player 2 playing from correct point in time", () => getPlayer(PLAYER_2_ID).ChildrenOfType().Single().FrameStableClock.CurrentTime > 30000);
@@ -357,12 +364,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
///
/// Tests spectating with a beatmap that has a high value.
+ ///
+ /// This test is not intended not to check the correct initial time value, but only to guard against
+ /// gameplay potentially getting stuck in a stopped state due to lead in time being present.
///
[Test]
public void TestAudioLeadIn() => testLeadIn(b => b.BeatmapInfo.AudioLeadIn = 2000);
///
/// Tests spectating with a beatmap that has a storyboard element with a negative start time (i.e. intro storyboard element).
+ ///
+ /// This test is not intended not to check the correct initial time value, but only to guard against
+ /// gameplay potentially getting stuck in a stopped state due to lead in time being present.
///
[Test]
public void TestIntroStoryboardElement() => testLeadIn(b =>
@@ -384,10 +397,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for player load", () => spectatorScreen.AllPlayersLoaded);
- AddWaitStep("wait for progression", 3);
+ AddUntilStep("wait for clock running", () => getInstance(PLAYER_1_ID).SpectatorPlayerClock.IsRunning);
assertNotCatchingUp(PLAYER_1_ID);
- assertRunning(PLAYER_1_ID);
+ waitUntilRunning(PLAYER_1_ID);
}
private void loadSpectateScreen(bool waitForPlayerLoad = true, Action? applyToBeatmap = null)
@@ -439,6 +452,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
}
+ ///
+ /// Send new frames on behalf of a user.
+ /// Frames will last for count * 100 milliseconds.
+ ///
private void sendFrames(int userId, int count = 10) => sendFrames(new[] { userId }, count);
private void sendFrames(int[] userIds, int count = 10)
@@ -450,30 +467,41 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
}
- private void checkPaused(int userId, bool state)
- => AddUntilStep($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().IsRunning != state);
-
- private void checkPausedInstant(int userId, bool state)
+ private void checkRunningInstant(int userId)
{
- checkPaused(userId, state);
+ waitUntilRunning(userId);
// Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time.
// AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state);
}
- private void assertOneNotMuted() => AddAssert("one player not muted", () => spectatorScreen.ChildrenOfType().Count(p => !p.Mute) == 1);
+ private void checkPausedInstant(int userId)
+ {
+ waitUntilPaused(userId);
+
+ // Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time.
+ // AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state);
+ }
+
+ private void assertOnePlayerNotMuted() => AddAssert(nameof(assertOnePlayerNotMuted), () => spectatorScreen.ChildrenOfType().Count(p => !p.Mute) == 1);
private void assertMuted(int userId, bool muted)
- => AddAssert($"{userId} {(muted ? "is" : "is not")} muted", () => getInstance(userId).Mute == muted);
+ => AddAssert($"{nameof(assertMuted)}({userId}, {muted})", () => getInstance(userId).Mute == muted);
private void assertRunning(int userId)
- => AddAssert($"{userId} clock running", () => getInstance(userId).GameplayClock.IsRunning);
+ => AddAssert($"{nameof(assertRunning)}({userId})", () => getInstance(userId).SpectatorPlayerClock.IsRunning);
+
+ private void waitUntilPaused(int userId)
+ => AddUntilStep($"{nameof(waitUntilPaused)}({userId})", () => !getPlayer(userId).ChildrenOfType().First().IsRunning);
+
+ private void waitUntilRunning(int userId)
+ => AddUntilStep($"{nameof(waitUntilRunning)}({userId})", () => getPlayer(userId).ChildrenOfType().First().IsRunning);
private void assertNotCatchingUp(int userId)
- => AddAssert($"{userId} in sync", () => !getInstance(userId).GameplayClock.IsCatchingUp);
+ => AddAssert($"{nameof(assertNotCatchingUp)}({userId})", () => !getInstance(userId).SpectatorPlayerClock.IsCatchingUp);
private void waitForCatchup(int userId)
- => AddUntilStep($"{userId} not catching up", () => !getInstance(userId).GameplayClock.IsCatchingUp);
+ => AddUntilStep($"{nameof(waitForCatchup)}({userId})", () => !getInstance(userId).SpectatorPlayerClock.IsCatchingUp);
private Player getPlayer(int userId) => getInstance(userId).ChildrenOfType().Single();
diff --git a/osu.Game/Beatmaps/FramedBeatmapClock.cs b/osu.Game/Beatmaps/FramedBeatmapClock.cs
new file mode 100644
index 0000000000..c86f25640f
--- /dev/null
+++ b/osu.Game/Beatmaps/FramedBeatmapClock.cs
@@ -0,0 +1,213 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Diagnostics;
+using osu.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Audio.Track;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Timing;
+using osu.Game.Configuration;
+using osu.Game.Database;
+using osu.Game.Screens.Play;
+
+namespace osu.Game.Beatmaps
+{
+ ///
+ /// A clock intended to be the single source-of-truth for beatmap timing.
+ ///
+ /// It provides some functionality:
+ /// - Optionally applies (and tracks changes of) beatmap, user, and platform offsets (see ctor argument applyOffsets).
+ /// - Adjusts operations to account for any applied offsets, seeking in raw "beatmap" time values.
+ /// - Exposes track length.
+ /// - Allows changing the source to a new track (for cases like editor track updating).
+ ///
+ public class FramedBeatmapClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock
+ {
+ private readonly bool applyOffsets;
+
+ ///
+ /// The length of the underlying beatmap track. Will default to 60 seconds if unavailable.
+ ///
+ public double TrackLength => Track.Length;
+
+ ///
+ /// The underlying beatmap track, if available.
+ ///
+ public Track Track { get; private set; } = new TrackVirtual(60000);
+
+ ///
+ /// The total frequency adjustment from pause transforms. Should eventually be handled in a better way.
+ ///
+ public readonly BindableDouble ExternalPauseFrequencyAdjust = new BindableDouble(1);
+
+ private readonly OffsetCorrectionClock? userGlobalOffsetClock;
+ private readonly OffsetCorrectionClock? platformOffsetClock;
+ private readonly OffsetCorrectionClock? userBeatmapOffsetClock;
+
+ private readonly IFrameBasedClock finalClockSource;
+
+ private Bindable? userAudioOffset;
+
+ private IDisposable? beatmapOffsetSubscription;
+
+ private readonly DecoupleableInterpolatingFramedClock decoupledClock;
+
+ [Resolved]
+ private OsuConfigManager config { get; set; } = null!;
+
+ [Resolved]
+ private RealmAccess realm { get; set; } = null!;
+
+ [Resolved]
+ private IBindable beatmap { get; set; } = null!;
+
+ public bool IsCoupled
+ {
+ get => decoupledClock.IsCoupled;
+ set => decoupledClock.IsCoupled = value;
+ }
+
+ public FramedBeatmapClock(bool applyOffsets = false)
+ {
+ this.applyOffsets = applyOffsets;
+
+ // A decoupled clock is used to ensure precise time values even when the host audio subsystem is not reporting
+ // high precision times (on windows there's generally only 5-10ms reporting intervals, as an example).
+ decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true };
+
+ if (applyOffsets)
+ {
+ // Audio timings in general with newer BASS versions don't match stable.
+ // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck.
+ platformOffsetClock = new OffsetCorrectionClock(decoupledClock, ExternalPauseFrequencyAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
+
+ // User global offset (set in settings) should also be applied.
+ userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, ExternalPauseFrequencyAdjust);
+
+ // User per-beatmap offset will be applied to this final clock.
+ finalClockSource = userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, ExternalPauseFrequencyAdjust);
+ }
+ else
+ {
+ finalClockSource = decoupledClock;
+ }
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ if (applyOffsets)
+ {
+ Debug.Assert(userBeatmapOffsetClock != null);
+ Debug.Assert(userGlobalOffsetClock != null);
+
+ userAudioOffset = config.GetBindable(OsuSetting.AudioOffset);
+ userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true);
+
+ beatmapOffsetSubscription = realm.SubscribeToPropertyChanged(
+ r => r.Find(beatmap.Value.BeatmapInfo.ID)?.UserSettings,
+ settings => settings.Offset,
+ val =>
+ {
+ userBeatmapOffsetClock.Offset = val;
+ });
+ }
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+ finalClockSource.ProcessFrame();
+ }
+
+ private double totalAppliedOffset
+ {
+ get
+ {
+ if (!applyOffsets)
+ return 0;
+
+ Debug.Assert(userGlobalOffsetClock != null);
+ Debug.Assert(userBeatmapOffsetClock != null);
+ Debug.Assert(platformOffsetClock != null);
+
+ return userGlobalOffsetClock.RateAdjustedOffset + userBeatmapOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset;
+ }
+ }
+
+ #region Delegation of IAdjustableClock / ISourceChangeableClock to decoupled clock.
+
+ public void ChangeSource(IClock? source)
+ {
+ Track = source as Track ?? new TrackVirtual(60000);
+ decoupledClock.ChangeSource(source);
+ }
+
+ public IClock? Source => decoupledClock.Source;
+
+ public void Reset()
+ {
+ decoupledClock.Reset();
+ finalClockSource.ProcessFrame();
+ }
+
+ public void Start()
+ {
+ decoupledClock.Start();
+ finalClockSource.ProcessFrame();
+ }
+
+ public void Stop()
+ {
+ decoupledClock.Stop();
+ finalClockSource.ProcessFrame();
+ }
+
+ public bool Seek(double position)
+ {
+ bool success = decoupledClock.Seek(position - totalAppliedOffset);
+ finalClockSource.ProcessFrame();
+
+ return success;
+ }
+
+ public void ResetSpeedAdjustments() => decoupledClock.ResetSpeedAdjustments();
+
+ public double Rate
+ {
+ get => decoupledClock.Rate;
+ set => decoupledClock.Rate = value;
+ }
+
+ #endregion
+
+ #region Delegation of IFrameBasedClock to clock with all offsets applied
+
+ public double CurrentTime => finalClockSource.CurrentTime;
+
+ public bool IsRunning => finalClockSource.IsRunning;
+
+ public void ProcessFrame()
+ {
+ // Noop to ensure an external consumer doesn't process the internal clock an extra time.
+ }
+
+ public double ElapsedFrameTime => finalClockSource.ElapsedFrameTime;
+
+ public double FramesPerSecond => finalClockSource.FramesPerSecond;
+
+ public FrameTimeInfo TimeInfo => finalClockSource.TimeInfo;
+
+ #endregion
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+ beatmapOffsetSubscription?.Dispose();
+ }
+ }
+}
diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
index 8f3e077050..18d0ff0bed 100644
--- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
+++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
@@ -281,7 +281,7 @@ namespace osu.Game.Rulesets.UI
}
}
- public double? StartTime => parentGameplayClock?.StartTime;
+ public double StartTime => parentGameplayClock?.StartTime ?? 0;
public IEnumerable NonGameplayAdjustments => parentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty();
diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs
index fd230a97bc..94975b6b5e 100644
--- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs
+++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs
@@ -27,7 +27,11 @@ namespace osu.Game.Screens.Edit.GameplayTest
}
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
- => new MasterGameplayClockContainer(beatmap, gameplayStart) { StartTime = editorState.Time };
+ {
+ var masterGameplayClockContainer = new MasterGameplayClockContainer(beatmap, gameplayStart);
+ masterGameplayClockContainer.Reset(editorState.Time);
+ return masterGameplayClockContainer;
+ }
protected override void LoadComplete()
{
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
index cb797d7aff..a42aa4ba93 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
@@ -132,7 +132,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
}, _ =>
{
foreach (var instance in instances)
- leaderboard.AddClock(instance.UserId, instance.GameplayClock);
+ leaderboard.AddClock(instance.UserId, instance.SpectatorPlayerClock);
leaderboardFlow.Insert(0, leaderboard);
@@ -163,10 +163,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
base.Update();
- if (!isCandidateAudioSource(currentAudioSource?.GameplayClock))
+ if (!isCandidateAudioSource(currentAudioSource?.SpectatorPlayerClock))
{
- currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock))
- .OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.CurrentMasterTime))
+ currentAudioSource = instances.Where(i => isCandidateAudioSource(i.SpectatorPlayerClock))
+ .OrderBy(i => Math.Abs(i.SpectatorPlayerClock.CurrentTime - syncManager.CurrentMasterTime))
.FirstOrDefault();
foreach (var instance in instances)
@@ -187,8 +187,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
.DefaultIfEmpty(0)
.Min();
- masterClockContainer.StartTime = startTime;
- masterClockContainer.Reset(true);
+ masterClockContainer.Reset(startTime, true);
}
protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState)
@@ -216,7 +215,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
var instance = instances.Single(i => i.UserId == userId);
instance.FadeColour(colours.Gray4, 400, Easing.OutQuint);
- syncManager.RemoveManagedClock(instance.GameplayClock);
+ syncManager.RemoveManagedClock(instance.SpectatorPlayerClock);
}
public override bool OnBackButton()
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs
index a1fbdc10de..36f6631ebf 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs
@@ -38,9 +38,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
public readonly int UserId;
///
- /// The used to control the gameplay running state of a loaded .
+ /// The used to control the gameplay running state of a loaded .
///
- public readonly SpectatorPlayerClock GameplayClock;
+ public readonly SpectatorPlayerClock SpectatorPlayerClock;
///
/// The currently-loaded score.
@@ -58,7 +58,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
public PlayerArea(int userId, SpectatorPlayerClock clock)
{
UserId = userId;
- GameplayClock = clock;
+ SpectatorPlayerClock = clock;
RelativeSizeAxes = Axes.Both;
Masking = true;
@@ -95,7 +95,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
stack.Push(new MultiSpectatorPlayerLoader(Score, () =>
{
- var player = new MultiSpectatorPlayer(Score, GameplayClock);
+ var player = new MultiSpectatorPlayer(Score, SpectatorPlayerClock);
player.OnGameplayStarted += () => OnGameplayStarted?.Invoke();
return player;
}));
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs
index 7801f22437..45615d4e19 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs
@@ -77,7 +77,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
if (IsRunning)
{
- double elapsedSource = masterClock.ElapsedFrameTime;
+ // When in catch-up mode, the source is usually not running.
+ // In such a case, its elapsed time may be zero, which would cause catch-up to get stuck.
+ // To avoid this, use a constant 16ms elapsed time for now. Probably not too correct, but this whole logic isn't too correct anyway.
+ // Clamping is required to ensure that player clocks don't get too far ahead if ProcessFrame is run multiple times.
+ double elapsedSource = masterClock.ElapsedFrameTime != 0 ? masterClock.ElapsedFrameTime : Math.Clamp(masterClock.CurrentTime - CurrentTime, 0, 16);
double elapsed = elapsedSource * Rate;
CurrentTime += elapsed;
diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs
index ac846b45c4..1ae393d06a 100644
--- a/osu.Game/Screens/Play/GameplayClockContainer.cs
+++ b/osu.Game/Screens/Play/GameplayClockContainer.cs
@@ -11,12 +11,14 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Timing;
using osu.Framework.Utils;
+using osu.Game.Beatmaps;
namespace osu.Game.Screens.Play
{
///
/// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use.
///
+ [Cached(typeof(IGameplayClock))]
public class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock
{
///
@@ -36,119 +38,137 @@ namespace osu.Game.Screens.Play
///
/// The time from which the clock should start. Will be seeked to on calling .
+ /// Can be adjusted by calling with a time value.
///
///
- /// If not set, a value of zero will be used.
- /// Importantly, the value will be inferred from the current ruleset in unless specified.
+ /// By default, a value of zero will be used.
+ /// Importantly, the value will be inferred from the current beatmap in by default.
///
- public double? StartTime { get; set; }
+ public double StartTime { get; protected set; }
public virtual IEnumerable NonGameplayAdjustments => Enumerable.Empty();
- ///
- /// The final clock which is exposed to gameplay components.
- ///
- protected IFrameBasedClock FramedClock { get; private set; }
-
private readonly BindableBool isPaused = new BindableBool(true);
///
/// The adjustable source clock used for gameplay. Should be used for seeks and clock control.
+ /// This is the final source exposed to gameplay components via delegation in this class.
///
- private readonly DecoupleableInterpolatingFramedClock decoupledClock;
+ protected readonly FramedBeatmapClock GameplayClock;
+
+ protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
///
/// Creates a new .
///
/// The source used for timing.
- public GameplayClockContainer(IClock sourceClock)
+ /// Whether to apply platform, user and beatmap offsets to the mix.
+ public GameplayClockContainer(IClock sourceClock, bool applyOffsets = false)
{
SourceClock = sourceClock;
RelativeSizeAxes = Axes.Both;
- decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = false };
- IsPaused.BindValueChanged(OnIsPausedChanged);
-
- // this will be replaced during load, but non-null for tests which don't add this component to the hierarchy.
- FramedClock = new FramedClock();
- }
-
- protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
- {
- var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
-
- FramedClock = CreateGameplayClock(decoupledClock);
-
- dependencies.CacheAs(this);
-
- return dependencies;
+ InternalChildren = new Drawable[]
+ {
+ GameplayClock = new FramedBeatmapClock(applyOffsets) { IsCoupled = false },
+ Content
+ };
}
///
- /// Starts gameplay.
+ /// Starts gameplay and marks un-paused state.
///
- public virtual void Start()
+ public void Start()
{
- ensureSourceClockSet();
-
- if (!decoupledClock.IsRunning)
- {
- // Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time
- // This accounts for the clock source potentially taking time to enter a completely stopped state
- Seek(FramedClock.CurrentTime);
-
- decoupledClock.Start();
- }
+ if (!isPaused.Value)
+ return;
isPaused.Value = false;
+
+ ensureSourceClockSet();
+
+ // Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time
+ // This accounts for the clock source potentially taking time to enter a completely stopped state
+ Seek(GameplayClock.CurrentTime);
+
+ // The case which caused this to be added is FrameStabilityContainer, which manages its own current and elapsed time.
+ // Because we generally update our own current time quicker than children can query it (via Start/Seek/Update),
+ // this means that the first frame ever exposed to children may have a non-zero current time.
+ //
+ // If the child component is not aware of the parent ElapsedFrameTime (which is the case for FrameStabilityContainer)
+ // they will take on the new CurrentTime with a zero elapsed time. This can in turn cause components to behave incorrectly
+ // if they are intending to trigger events at the precise StartTime (ie. DrawableStoryboardSample).
+ //
+ // By scheduling the start call, children are guaranteed to receive one frame at the original start time, allowing
+ // then to progress with a correct locally calculated elapsed time.
+ SchedulerAfterChildren.Add(() =>
+ {
+ if (isPaused.Value)
+ return;
+
+ StartGameplayClock();
+ });
}
///
/// Seek to a specific time in gameplay.
///
/// The destination time to seek to.
- public virtual void Seek(double time)
+ public void Seek(double time)
{
Logger.Log($"{nameof(GameplayClockContainer)} seeking to {time}");
- decoupledClock.Seek(time);
-
- // Manually process to make sure the gameplay clock is correctly updated after a seek.
- FramedClock.ProcessFrame();
+ GameplayClock.Seek(time);
OnSeek?.Invoke();
}
///
- /// Stops gameplay.
+ /// Stops gameplay and marks paused state.
///
- public void Stop() => isPaused.Value = true;
+ public void Stop()
+ {
+ if (isPaused.Value)
+ return;
+
+ isPaused.Value = true;
+ StopGameplayClock();
+ }
+
+ protected virtual void StartGameplayClock() => GameplayClock.Start();
+ protected virtual void StopGameplayClock() => GameplayClock.Stop();
///
/// Resets this and the source to an initial state ready for gameplay.
///
+ /// The time to seek to on resetting. If null, the existing will be used.
/// Whether to start the clock immediately, if not already started.
- public void Reset(bool startClock = false)
+ public void Reset(double? time = null, bool startClock = false)
{
- // Manually stop the source in order to not affect the IsPaused state.
- decoupledClock.Stop();
+ bool wasPaused = isPaused.Value;
- if (!IsPaused.Value || startClock)
- Start();
+ Stop();
ensureSourceClockSet();
- Seek(StartTime ?? 0);
+
+ if (time != null)
+ StartTime = time.Value;
+
+ Seek(StartTime);
+
+ if (!wasPaused || startClock)
+ Start();
}
///
/// Changes the source clock.
///
/// The new source.
- protected void ChangeSource(IClock sourceClock) => decoupledClock.ChangeSource(SourceClock = sourceClock);
+ protected void ChangeSource(IClock sourceClock) => GameplayClock.ChangeSource(SourceClock = sourceClock);
///
- /// Ensures that the is set to , if it hasn't been given a source yet.
+ /// Ensures that the is set to , if it hasn't been given a source yet.
/// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode,
/// but not the actual source clock.
/// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor,
@@ -156,40 +176,10 @@ namespace osu.Game.Screens.Play
///
private void ensureSourceClockSet()
{
- if (decoupledClock.Source == null)
+ if (GameplayClock.Source == null)
ChangeSource(SourceClock);
}
- protected override void Update()
- {
- if (!IsPaused.Value)
- FramedClock.ProcessFrame();
-
- base.Update();
- }
-
- ///
- /// Invoked when the value of is changed to start or stop the clock.
- ///
- /// Whether the clock should now be paused.
- protected virtual void OnIsPausedChanged(ValueChangedEvent isPaused)
- {
- if (isPaused.NewValue)
- decoupledClock.Stop();
- else
- decoupledClock.Start();
- }
-
- ///
- /// Creates the final which is exposed via DI to be used by gameplay components.
- ///
- ///
- /// Any intermediate clocks such as platform offsets should be applied here.
- ///
- /// The providing the source time.
- /// The final .
- protected virtual IFrameBasedClock CreateGameplayClock(IFrameBasedClock source) => source;
-
#region IAdjustableClock
bool IAdjustableClock.Seek(double position)
@@ -204,15 +194,15 @@ namespace osu.Game.Screens.Play
double IAdjustableClock.Rate
{
- get => FramedClock.Rate;
+ get => GameplayClock.Rate;
set => throw new NotSupportedException();
}
- public double Rate => FramedClock.Rate;
+ public double Rate => GameplayClock.Rate;
- public double CurrentTime => FramedClock.CurrentTime;
+ public double CurrentTime => GameplayClock.CurrentTime;
- public bool IsRunning => FramedClock.IsRunning;
+ public bool IsRunning => GameplayClock.IsRunning;
#endregion
@@ -221,11 +211,11 @@ namespace osu.Game.Screens.Play
// Handled via update. Don't process here to safeguard from external usages potentially processing frames additional times.
}
- public double ElapsedFrameTime => FramedClock.ElapsedFrameTime;
+ public double ElapsedFrameTime => GameplayClock.ElapsedFrameTime;
- public double FramesPerSecond => FramedClock.FramesPerSecond;
+ public double FramesPerSecond => GameplayClock.FramesPerSecond;
- public FrameTimeInfo TimeInfo => FramedClock.TimeInfo;
+ public FrameTimeInfo TimeInfo => GameplayClock.TimeInfo;
public double TrueGameplayRate
{
diff --git a/osu.Game/Screens/Play/HUD/SongProgress.cs b/osu.Game/Screens/Play/HUD/SongProgress.cs
index f368edbfb9..0b6494bd8a 100644
--- a/osu.Game/Screens/Play/HUD/SongProgress.cs
+++ b/osu.Game/Screens/Play/HUD/SongProgress.cs
@@ -82,7 +82,7 @@ namespace osu.Game.Screens.Play.HUD
if (isInIntro)
{
- double introStartTime = GameplayClock.StartTime ?? 0;
+ double introStartTime = GameplayClock.StartTime;
double introOffsetCurrent = currentTime - introStartTime;
double introDuration = FirstHitTime - introStartTime;
diff --git a/osu.Game/Screens/Play/IGameplayClock.cs b/osu.Game/Screens/Play/IGameplayClock.cs
index 5f54ce691a..ea567090ad 100644
--- a/osu.Game/Screens/Play/IGameplayClock.cs
+++ b/osu.Game/Screens/Play/IGameplayClock.cs
@@ -19,10 +19,10 @@ namespace osu.Game.Screens.Play
/// The time from which the clock should start. Will be seeked to on calling .
///
///
- /// If not set, a value of zero will be used.
- /// Importantly, the value will be inferred from the current ruleset in unless specified.
+ /// By default, a value of zero will be used.
+ /// Importantly, the value will be inferred from the current beatmap in by default.
///
- double? StartTime { get; }
+ double StartTime { get; }
///
/// All adjustments applied to this clock which don't come from gameplay or mods.
diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs
index d26f0c6311..238817ad05 100644
--- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs
+++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs
@@ -4,8 +4,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using osu.Framework;
-using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
@@ -13,8 +11,6 @@ using osu.Framework.Graphics;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
-using osu.Game.Configuration;
-using osu.Game.Database;
namespace osu.Game.Screens.Play
{
@@ -43,28 +39,10 @@ namespace osu.Game.Screens.Play
Precision = 0.1,
};
- private double totalAppliedOffset => userBeatmapOffsetClock.RateAdjustedOffset + userGlobalOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset;
-
- private readonly BindableDouble pauseFreqAdjust = new BindableDouble(); // Important that this starts at zero, matching the paused state of the clock.
-
private readonly WorkingBeatmap beatmap;
- private OffsetCorrectionClock userGlobalOffsetClock = null!;
- private OffsetCorrectionClock userBeatmapOffsetClock = null!;
- private OffsetCorrectionClock platformOffsetClock = null!;
-
- private Bindable userAudioOffset = null!;
-
- private IDisposable? beatmapOffsetSubscription;
-
private readonly double skipTargetTime;
- [Resolved]
- private RealmAccess realm { get; set; } = null!;
-
- [Resolved]
- private OsuConfigManager config { get; set; } = null!;
-
private readonly List> nonGameplayAdjustments = new List>();
public override IEnumerable NonGameplayAdjustments => nonGameplayAdjustments.Select(b => b.Value);
@@ -75,32 +53,12 @@ namespace osu.Game.Screens.Play
/// The beatmap to be used for time and metadata references.
/// The latest time which should be used when introducing gameplay. Will be used when skipping forward.
public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime)
- : base(beatmap.Track)
+ : base(beatmap.Track, true)
{
this.beatmap = beatmap;
this.skipTargetTime = skipTargetTime;
- }
- protected override void LoadComplete()
- {
- base.LoadComplete();
-
- userAudioOffset = config.GetBindable(OsuSetting.AudioOffset);
- userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true);
-
- beatmapOffsetSubscription = realm.SubscribeToPropertyChanged(
- r => r.Find(beatmap.BeatmapInfo.ID)?.UserSettings,
- settings => settings.Offset,
- val => userBeatmapOffsetClock.Offset = val);
-
- // Reset may have been called externally before LoadComplete.
- // If it was, and the clock is in a playing state, we want to ensure that it isn't stopped here.
- bool isStarted = !IsPaused.Value;
-
- // If a custom start time was not specified, calculate the best value to use.
- StartTime ??= findEarliestStartTime();
-
- Reset(startClock: isStarted);
+ StartTime = findEarliestStartTime();
}
private double findEarliestStartTime()
@@ -126,54 +84,49 @@ namespace osu.Game.Screens.Play
return time;
}
- protected override void OnIsPausedChanged(ValueChangedEvent isPaused)
+ protected override void StopGameplayClock()
{
if (IsLoaded)
{
// During normal operation, the source is stopped after performing a frequency ramp.
- if (isPaused.NewValue)
+ this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 0, 200, Easing.Out).OnComplete(_ =>
{
- this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ =>
- {
- if (IsPaused.Value == isPaused.NewValue)
- base.OnIsPausedChanged(isPaused);
- });
- }
- else
- this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In);
+ if (IsPaused.Value)
+ base.StopGameplayClock();
+ });
}
else
{
- if (isPaused.NewValue)
- base.OnIsPausedChanged(isPaused);
+ base.StopGameplayClock();
// If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations.
- pauseFreqAdjust.Value = isPaused.NewValue ? 0 : 1;
+ GameplayClock.ExternalPauseFrequencyAdjust.Value = 0;
// We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment.
// Without doing this, an initial seek may be performed with the wrong offset.
- FramedClock.ProcessFrame();
+ GameplayClock.ProcessFrame();
}
}
- public override void Start()
+ protected override void StartGameplayClock()
{
addSourceClockAdjustments();
- base.Start();
- }
- ///
- /// Seek to a specific time in gameplay.
- ///
- ///
- /// Adjusts for any offsets which have been applied (so the seek may not be the expected point in time on the underlying audio track).
- ///
- /// The destination time to seek to.
- public override void Seek(double time)
- {
- // remove the offset component here because most of the time we want the seek to be aligned to gameplay, not the audio track.
- // we may want to consider reversing the application of offsets in the future as it may feel more correct.
- base.Seek(time - totalAppliedOffset);
+ base.StartGameplayClock();
+
+ if (IsLoaded)
+ {
+ this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 1, 200, Easing.In);
+ }
+ else
+ {
+ // If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations.
+ GameplayClock.ExternalPauseFrequencyAdjust.Value = 1;
+
+ // We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment.
+ // Without doing this, an initial seek may be performed with the wrong offset.
+ GameplayClock.ProcessFrame();
+ }
}
///
@@ -181,29 +134,18 @@ namespace osu.Game.Screens.Play
///
public void Skip()
{
- if (FramedClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
+ if (GameplayClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
return;
double skipTarget = skipTargetTime - MINIMUM_SKIP_TIME;
- if (FramedClock.CurrentTime < 0 && skipTarget > 6000)
+ if (GameplayClock.CurrentTime < 0 && skipTarget > 6000)
// double skip exception for storyboards with very long intros
skipTarget = 0;
Seek(skipTarget);
}
- protected override IFrameBasedClock CreateGameplayClock(IFrameBasedClock source)
- {
- // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited.
- // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck.
- platformOffsetClock = new OffsetCorrectionClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
-
- // the final usable gameplay clock with user-set offsets applied.
- userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, pauseFreqAdjust);
- return userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, pauseFreqAdjust);
- }
-
///
/// Changes the backing clock to avoid using the originally provided track.
///
@@ -224,10 +166,10 @@ namespace osu.Game.Screens.Play
if (SourceClock is not Track track)
return;
- track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
+ track.AddAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust);
track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
- nonGameplayAdjustments.Add(pauseFreqAdjust);
+ nonGameplayAdjustments.Add(GameplayClock.ExternalPauseFrequencyAdjust);
nonGameplayAdjustments.Add(UserPlaybackRate);
speedAdjustmentsApplied = true;
@@ -241,10 +183,10 @@ namespace osu.Game.Screens.Play
if (SourceClock is not Track track)
return;
- track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
+ track.RemoveAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust);
track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
- nonGameplayAdjustments.Remove(pauseFreqAdjust);
+ nonGameplayAdjustments.Remove(GameplayClock.ExternalPauseFrequencyAdjust);
nonGameplayAdjustments.Remove(UserPlaybackRate);
speedAdjustmentsApplied = false;
@@ -253,7 +195,6 @@ namespace osu.Game.Screens.Play
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
- beatmapOffsetSubscription?.Dispose();
removeSourceClockAdjustments();
}
diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index 6827ff04d3..d8db41c833 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -640,8 +640,7 @@ namespace osu.Game.Screens.Play
bool wasFrameStable = DrawableRuleset.FrameStablePlayback;
DrawableRuleset.FrameStablePlayback = false;
- GameplayClockContainer.StartTime = time;
- GameplayClockContainer.Reset();
+ GameplayClockContainer.Reset(time);
// Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek.
frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable);
@@ -1012,7 +1011,7 @@ namespace osu.Game.Screens.Play
if (GameplayClockContainer.IsRunning)
throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running");
- GameplayClockContainer.Reset(true);
+ GameplayClockContainer.Reset(startClock: true);
}
public override void OnSuspending(ScreenTransitionEvent e)
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 0613db891b..5fc69e475f 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -36,7 +36,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index bf1e4e350c..f763e411be 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -61,7 +61,7 @@
-
+
@@ -84,7 +84,7 @@
-
+