mirror of
https://github.com/ppy/osu.git
synced 2025-01-28 07:23:14 +08:00
Merge pull request #19828 from peppy/no-gameplay-clock-gameplay-offset
Introduce `FramedBeatmapClock` (and use in gameplay flow)
This commit is contained in:
commit
91e044542d
@ -52,7 +52,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.819.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.825.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Transitive Dependencies">
|
||||
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sample at 0ms, start time at 1000ms (so the sample should not be played).
|
||||
/// </summary>
|
||||
[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);
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -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<DrawableRuleset>().First().FrameStableClock.CurrentTime > 30000);
|
||||
AddUntilStep("playing from correct point in time", () => player.ChildrenOfType<DrawableRuleset>().First().FrameStableClock.CurrentTime, () => Is.GreaterThan(30000));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
@ -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<MasterGameplayClockContainer>().Single().IsRunning);
|
||||
|
||||
checkPaused(PLAYER_1_ID, true);
|
||||
waitUntilPaused(PLAYER_1_ID);
|
||||
AddUntilStep("master clock paused", () => !this.ChildrenOfType<MasterGameplayClockContainer>().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<DrawableRuleset>().Single().FrameStableClock.CurrentTime > 30000);
|
||||
@ -357,12 +364,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
|
||||
/// <summary>
|
||||
/// Tests spectating with a beatmap that has a high <see cref="BeatmapInfo.AudioLeadIn"/> 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.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TestAudioLeadIn() => testLeadIn(b => b.BeatmapInfo.AudioLeadIn = 2000);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<WorkingBeatmap>? applyToBeatmap = null)
|
||||
@ -439,6 +452,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send new frames on behalf of a user.
|
||||
/// Frames will last for count * 100 milliseconds.
|
||||
/// </summary>
|
||||
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<GameplayClockContainer>().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<GameplayClockContainer>().First().GameplayClock.IsRunning != state);
|
||||
}
|
||||
|
||||
private void assertOneNotMuted() => AddAssert("one player not muted", () => spectatorScreen.ChildrenOfType<PlayerArea>().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<GameplayClockContainer>().First().GameplayClock.IsRunning != state);
|
||||
}
|
||||
|
||||
private void assertOnePlayerNotMuted() => AddAssert(nameof(assertOnePlayerNotMuted), () => spectatorScreen.ChildrenOfType<PlayerArea>().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<GameplayClockContainer>().First().IsRunning);
|
||||
|
||||
private void waitUntilRunning(int userId)
|
||||
=> AddUntilStep($"{nameof(waitUntilRunning)}({userId})", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().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<Player>().Single();
|
||||
|
||||
|
213
osu.Game/Beatmaps/FramedBeatmapClock.cs
Normal file
213
osu.Game/Beatmaps/FramedBeatmapClock.cs
Normal file
@ -0,0 +1,213 @@
|
||||
// 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.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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="Seek"/> 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).
|
||||
/// </summary>
|
||||
public class FramedBeatmapClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock
|
||||
{
|
||||
private readonly bool applyOffsets;
|
||||
|
||||
/// <summary>
|
||||
/// The length of the underlying beatmap track. Will default to 60 seconds if unavailable.
|
||||
/// </summary>
|
||||
public double TrackLength => Track.Length;
|
||||
|
||||
/// <summary>
|
||||
/// The underlying beatmap track, if available.
|
||||
/// </summary>
|
||||
public Track Track { get; private set; } = new TrackVirtual(60000);
|
||||
|
||||
/// <summary>
|
||||
/// The total frequency adjustment from pause transforms. Should eventually be handled in a better way.
|
||||
/// </summary>
|
||||
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<double>? 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<WorkingBeatmap> 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<double>(OsuSetting.AudioOffset);
|
||||
userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true);
|
||||
|
||||
beatmapOffsetSubscription = realm.SubscribeToPropertyChanged(
|
||||
r => r.Find<BeatmapInfo>(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();
|
||||
}
|
||||
}
|
||||
}
|
@ -281,7 +281,7 @@ namespace osu.Game.Rulesets.UI
|
||||
}
|
||||
}
|
||||
|
||||
public double? StartTime => parentGameplayClock?.StartTime;
|
||||
public double StartTime => parentGameplayClock?.StartTime ?? 0;
|
||||
|
||||
public IEnumerable<double> NonGameplayAdjustments => parentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty<double>();
|
||||
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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()
|
||||
|
@ -38,9 +38,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
||||
public readonly int UserId;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="SpectatorPlayerClock"/> used to control the gameplay running state of a loaded <see cref="Player"/>.
|
||||
/// The <see cref="Spectate.SpectatorPlayerClock"/> used to control the gameplay running state of a loaded <see cref="Player"/>.
|
||||
/// </summary>
|
||||
public readonly SpectatorPlayerClock GameplayClock;
|
||||
public readonly SpectatorPlayerClock SpectatorPlayerClock;
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}));
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Encapsulates gameplay timing logic and provides a <see cref="IGameplayClock"/> via DI for gameplay components to use.
|
||||
/// </summary>
|
||||
[Cached(typeof(IGameplayClock))]
|
||||
public class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock
|
||||
{
|
||||
/// <summary>
|
||||
@ -36,119 +38,137 @@ namespace osu.Game.Screens.Play
|
||||
|
||||
/// <summary>
|
||||
/// The time from which the clock should start. Will be seeked to on calling <see cref="Reset"/>.
|
||||
/// Can be adjusted by calling <see cref="Reset"/> with a time value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If not set, a value of zero will be used.
|
||||
/// Importantly, the value will be inferred from the current ruleset in <see cref="MasterGameplayClockContainer"/> unless specified.
|
||||
/// By default, a value of zero will be used.
|
||||
/// Importantly, the value will be inferred from the current beatmap in <see cref="MasterGameplayClockContainer"/> by default.
|
||||
/// </remarks>
|
||||
public double? StartTime { get; set; }
|
||||
public double StartTime { get; protected set; }
|
||||
|
||||
public virtual IEnumerable<double> NonGameplayAdjustments => Enumerable.Empty<double>();
|
||||
|
||||
/// <summary>
|
||||
/// The final clock which is exposed to gameplay components.
|
||||
/// </summary>
|
||||
protected IFrameBasedClock FramedClock { get; private set; }
|
||||
|
||||
private readonly BindableBool isPaused = new BindableBool(true);
|
||||
|
||||
/// <summary>
|
||||
/// The adjustable source clock used for gameplay. Should be used for seeks and clock control.
|
||||
/// This is the final source exposed to gameplay components <see cref="IGameplayClock"/> via delegation in this class.
|
||||
/// </summary>
|
||||
private readonly DecoupleableInterpolatingFramedClock decoupledClock;
|
||||
protected readonly FramedBeatmapClock GameplayClock;
|
||||
|
||||
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="GameplayClockContainer"/>.
|
||||
/// </summary>
|
||||
/// <param name="sourceClock">The source <see cref="IClock"/> used for timing.</param>
|
||||
public GameplayClockContainer(IClock sourceClock)
|
||||
/// <param name="applyOffsets">Whether to apply platform, user and beatmap offsets to the mix.</param>
|
||||
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<IGameplayClock>(this);
|
||||
|
||||
return dependencies;
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
GameplayClock = new FramedBeatmapClock(applyOffsets) { IsCoupled = false },
|
||||
Content
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts gameplay.
|
||||
/// Starts gameplay and marks un-paused state.
|
||||
/// </summary>
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seek to a specific time in gameplay.
|
||||
/// </summary>
|
||||
/// <param name="time">The destination time to seek to.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops gameplay.
|
||||
/// Stops gameplay and marks paused state.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Resets this <see cref="GameplayClockContainer"/> and the source to an initial state ready for gameplay.
|
||||
/// </summary>
|
||||
/// <param name="time">The time to seek to on resetting. If <c>null</c>, the existing <see cref="StartTime"/> will be used.</param>
|
||||
/// <param name="startClock">Whether to start the clock immediately, if not already started.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the source clock.
|
||||
/// </summary>
|
||||
/// <param name="sourceClock">The new source.</param>
|
||||
protected void ChangeSource(IClock sourceClock) => decoupledClock.ChangeSource(SourceClock = sourceClock);
|
||||
protected void ChangeSource(IClock sourceClock) => GameplayClock.ChangeSource(SourceClock = sourceClock);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the <see cref="decoupledClock"/> is set to <see cref="SourceClock"/>, if it hasn't been given a source yet.
|
||||
/// Ensures that the <see cref="GameplayClock"/> is set to <see cref="SourceClock"/>, 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
|
||||
/// </summary>
|
||||
private void ensureSourceClockSet()
|
||||
{
|
||||
if (decoupledClock.Source == null)
|
||||
if (GameplayClock.Source == null)
|
||||
ChangeSource(SourceClock);
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
if (!IsPaused.Value)
|
||||
FramedClock.ProcessFrame();
|
||||
|
||||
base.Update();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the value of <see cref="IsPaused"/> is changed to start or stop the <see cref="decoupledClock"/> clock.
|
||||
/// </summary>
|
||||
/// <param name="isPaused">Whether the clock should now be paused.</param>
|
||||
protected virtual void OnIsPausedChanged(ValueChangedEvent<bool> isPaused)
|
||||
{
|
||||
if (isPaused.NewValue)
|
||||
decoupledClock.Stop();
|
||||
else
|
||||
decoupledClock.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the final <see cref="FramedClock"/> which is exposed via DI to be used by gameplay components.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Any intermediate clocks such as platform offsets should be applied here.
|
||||
/// </remarks>
|
||||
/// <param name="source">The <see cref="IFrameBasedClock"/> providing the source time.</param>
|
||||
/// <returns>The final <see cref="FramedClock"/>.</returns>
|
||||
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
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -19,10 +19,10 @@ namespace osu.Game.Screens.Play
|
||||
/// The time from which the clock should start. Will be seeked to on calling <see cref="GameplayClockContainer.Reset"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If not set, a value of zero will be used.
|
||||
/// Importantly, the value will be inferred from the current ruleset in <see cref="MasterGameplayClockContainer"/> unless specified.
|
||||
/// By default, a value of zero will be used.
|
||||
/// Importantly, the value will be inferred from the current beatmap in <see cref="MasterGameplayClockContainer"/> by default.
|
||||
/// </remarks>
|
||||
double? StartTime { get; }
|
||||
double StartTime { get; }
|
||||
|
||||
/// <summary>
|
||||
/// All adjustments applied to this clock which don't come from gameplay or mods.
|
||||
|
@ -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<double> 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<Bindable<double>> nonGameplayAdjustments = new List<Bindable<double>>();
|
||||
|
||||
public override IEnumerable<double> NonGameplayAdjustments => nonGameplayAdjustments.Select(b => b.Value);
|
||||
@ -75,32 +53,12 @@ namespace osu.Game.Screens.Play
|
||||
/// <param name="beatmap">The beatmap to be used for time and metadata references.</param>
|
||||
/// <param name="skipTargetTime">The latest time which should be used when introducing gameplay. Will be used when skipping forward.</param>
|
||||
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<double>(OsuSetting.AudioOffset);
|
||||
userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true);
|
||||
|
||||
beatmapOffsetSubscription = realm.SubscribeToPropertyChanged(
|
||||
r => r.Find<BeatmapInfo>(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<bool> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seek to a specific time in gameplay.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Adjusts for any offsets which have been applied (so the seek may not be the expected point in time on the underlying audio track).
|
||||
/// </remarks>
|
||||
/// <param name="time">The destination time to seek to.</param>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -181,29 +134,18 @@ namespace osu.Game.Screens.Play
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the backing clock to avoid using the originally provided track.
|
||||
/// </summary>
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -36,7 +36,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Realm" Version="10.15.1" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2022.819.0" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2022.825.0" />
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
|
||||
<PackageReference Include="Sentry" Version="3.20.1" />
|
||||
<PackageReference Include="SharpCompress" Version="0.32.2" />
|
||||
|
@ -61,7 +61,7 @@
|
||||
<Reference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Package References">
|
||||
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.819.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.825.0" />
|
||||
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.819.0" />
|
||||
</ItemGroup>
|
||||
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
|
||||
@ -84,7 +84,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2022.819.0" />
|
||||
<PackageReference Include="ppy.osu.Framework" Version="2022.825.0" />
|
||||
<PackageReference Include="SharpCompress" Version="0.32.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
|
||||
|
Loading…
Reference in New Issue
Block a user