osu/osu.Game/Screens/Play/MasterGameplayClockContainer.cs
2021-04-21 16:33:14 +09:00

234 lines
9.2 KiB
C#

// 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.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;
using osu.Framework.Graphics;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
namespace osu.Game.Screens.Play
{
/// <summary>
/// A <see cref="GameplayClockContainer"/> which uses a <see cref="WorkingBeatmap"/> as a source.
/// <para>
/// This is the most complete <see cref="GameplayClockContainer"/> which takes into account all user and platform offsets,
/// and provides implementations for user actions such as skipping or adjusting playback rates that may occur during gameplay.
/// </para>
/// </summary>
/// <remarks>
/// This is intended to be used as a single controller for gameplay, or as a reference source for other <see cref="GameplayClockContainer"/>s.
/// </remarks>
public class MasterGameplayClockContainer : GameplayClockContainer
{
/// <summary>
/// Duration before gameplay start time required before skip button displays.
/// </summary>
public const double MINIMUM_SKIP_TIME = 1000;
protected Track Track => (Track)SourceClock;
public readonly BindableNumber<double> UserPlaybackRate = new BindableDouble(1)
{
Default = 1,
MinValue = 0.5,
MaxValue = 2,
Precision = 0.1,
};
private double totalOffset => userOffsetClock.Offset + platformOffsetClock.Offset;
private readonly BindableDouble pauseFreqAdjust = new BindableDouble(1);
private readonly WorkingBeatmap beatmap;
private readonly double gameplayStartTime;
private readonly bool startAtGameplayStart;
private readonly double firstHitObjectTime;
private FramedOffsetClock userOffsetClock;
private FramedOffsetClock platformOffsetClock;
private MasterGameplayClock masterGameplayClock;
private Bindable<double> userAudioOffset;
private double startOffset;
public MasterGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStartTime, bool startAtGameplayStart = false)
: base(beatmap.Track)
{
this.beatmap = beatmap;
this.gameplayStartTime = gameplayStartTime;
this.startAtGameplayStart = startAtGameplayStart;
firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime;
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
userAudioOffset = config.GetBindable<double>(OsuSetting.AudioOffset);
userAudioOffset.BindValueChanged(offset => userOffsetClock.Offset = offset.NewValue, true);
// sane default provided by ruleset.
startOffset = gameplayStartTime;
if (!startAtGameplayStart)
{
startOffset = Math.Min(0, startOffset);
// if a storyboard is present, it may dictate the appropriate start time by having events in negative time space.
// this is commonly used to display an intro before the audio track start.
double? firstStoryboardEvent = beatmap.Storyboard.EarliestEventTime;
if (firstStoryboardEvent != null)
startOffset = Math.Min(startOffset, firstStoryboardEvent.Value);
// some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available.
// this is not available as an option in the live editor but can still be applied via .osu editing.
if (beatmap.BeatmapInfo.AudioLeadIn > 0)
startOffset = Math.Min(startOffset, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn);
}
Seek(startOffset);
}
protected override void OnIsPausedChanged(ValueChangedEvent<bool> isPaused)
{
// The source is stopped by a frequency fade first.
if (isPaused.NewValue)
this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => AdjustableSource.Stop());
else
this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In);
}
public override void Start()
{
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 - totalOffset);
}
/// <summary>
/// Skip forward to the next valid skip point.
/// </summary>
public void Skip()
{
if (GameplayClock.CurrentTime > gameplayStartTime - MINIMUM_SKIP_TIME)
return;
double skipTarget = gameplayStartTime - MINIMUM_SKIP_TIME;
if (GameplayClock.CurrentTime < 0 && skipTarget > 6000)
// double skip exception for storyboards with very long intros
skipTarget = 0;
Seek(skipTarget);
}
public override void Reset()
{
base.Reset();
Seek(startOffset);
}
protected override GameplayClock 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 HardwareCorrectionOffsetClock(source) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
// the final usable gameplay clock with user-set offsets applied.
userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock);
return masterGameplayClock = new MasterGameplayClock(userOffsetClock);
}
/// <summary>
/// Changes the backing clock to avoid using the originally provided track.
/// </summary>
public void StopUsingBeatmapClock()
{
removeSourceClockAdjustments();
ChangeSource(new TrackVirtual(beatmap.Track.Length));
addSourceClockAdjustments();
}
private bool speedAdjustmentsApplied;
private void addSourceClockAdjustments()
{
if (speedAdjustmentsApplied)
return;
Track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
Track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
masterGameplayClock.MutableNonGameplayAdjustments.Add(pauseFreqAdjust);
masterGameplayClock.MutableNonGameplayAdjustments.Add(UserPlaybackRate);
speedAdjustmentsApplied = true;
}
private void removeSourceClockAdjustments()
{
if (!speedAdjustmentsApplied)
return;
Track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
Track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
masterGameplayClock.MutableNonGameplayAdjustments.Remove(pauseFreqAdjust);
masterGameplayClock.MutableNonGameplayAdjustments.Remove(UserPlaybackRate);
speedAdjustmentsApplied = false;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
removeSourceClockAdjustments();
}
private class HardwareCorrectionOffsetClock : FramedOffsetClock
{
// we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this.
// base implementation already adds offset at 1.0 rate, so we only add the difference from that here.
public override double CurrentTime => base.CurrentTime + Offset * (Rate - 1);
public HardwareCorrectionOffsetClock(IClock source, bool processSource = true)
: base(source, processSource)
{
}
}
private class MasterGameplayClock : GameplayClock
{
public readonly List<Bindable<double>> MutableNonGameplayAdjustments = new List<Bindable<double>>();
public override IEnumerable<Bindable<double>> NonGameplayAdjustments => MutableNonGameplayAdjustments;
public MasterGameplayClock(FramedOffsetClock underlyingClock)
: base(underlyingClock)
{
}
}
}
}