using UnityEngine; public static class GameplayClock { private static bool initialized; private static bool paused; private static double chartStartDspTime; private static double pauseStartDspTime; private static double accumulatedPauseSeconds; private static float chartStartSongTime; private static double scheduledMusicStartDsp; // anchor: when music actually starts playing // Seconds the chart clock leads the music clip position by (== globalDelaySeconds + userAudioOffset). // Captured when music is scheduled; used to preserve the songTime = musicTime + offset relationship // when re-anchoring to actual music progress after a pause. private static double musicToChartOffsetSeconds; // Per-frame cache of NowSongTime. AudioSettings.dspTime only advances on the audio // thread (~once per audio buffer), so it is effectively constant within a rendered // frame. Many notes/segments read NowSongTime every frame; caching by frame turns // hundreds of native dspTime reads per frame into one. Invalidated when the frame // changes or clock state mutates (Reset/StartChart/Pause/Resume). private static int nowSongTimeFrame = -1; private static float nowSongTimeCached; public static bool IsInitialized => initialized; public static bool IsPaused => paused; public static float ChartStartSongTime => chartStartSongTime; public static double ChartStartDspTime => chartStartDspTime; public static double ScheduledMusicStartDsp => scheduledMusicStartDsp; public static void Reset() { initialized = false; paused = false; chartStartDspTime = 0d; pauseStartDspTime = 0d; accumulatedPauseSeconds = 0d; chartStartSongTime = 0f; scheduledMusicStartDsp = 0d; musicToChartOffsetSeconds = 0d; nowSongTimeFrame = -1; } public static void StartChart(float initialSongTimeSeconds = 0f) { initialized = true; paused = false; chartStartDspTime = AudioSettings.dspTime; pauseStartDspTime = 0d; accumulatedPauseSeconds = 0d; chartStartSongTime = Mathf.Max(0f, initialSongTimeSeconds); scheduledMusicStartDsp = 0d; // will be set by SetMusicAnchor when music plays musicToChartOffsetSeconds = 0d; nowSongTimeFrame = -1; } /// /// Record the scheduled music start time for reference. /// Call this immediately after PlayScheduled or Play. /// NOTE: Does NOT re-anchor chartStartDspTime — the chart clock stays pinned to gameplay start (StartChart), /// and music simply plays offset into the future. Re-anchoring would shift all note timing by globalDelaySeconds. /// public static void SetMusicAnchor(double musicStartDspTime) { if (!initialized) return; scheduledMusicStartDsp = musicStartDspTime; // Do NOT re-anchor chartStartDspTime here — note spawn/hit times were computed from the original anchor. // Re-anchoring would create a systematic offset equal to globalDelaySeconds. // Capture the constant lead of chart song-time over music-clip time: songTime(D) - musicTime(D), // where musicTime(D) = D - musicStartDspTime. This lets ReanchorToMusicTime preserve the relationship. musicToChartOffsetSeconds = chartStartSongTime - chartStartDspTime - accumulatedPauseSeconds + musicStartDspTime; nowSongTimeFrame = -1; } public static void Pause() { if (!initialized || paused) { return; } paused = true; pauseStartDspTime = AudioSettings.dspTime; nowSongTimeFrame = -1; } public static void Resume() { if (!initialized || !paused) { return; } accumulatedPauseSeconds += AudioSettings.dspTime - pauseStartDspTime; pauseStartDspTime = 0d; paused = false; nowSongTimeFrame = -1; } /// /// Re-anchor the clock to the actual music progress on resume. /// Call this after Resume() if musicSource.time is available, to correct any drift. /// public static void ReanchorToMusicTime(float actualMusicTime) { if (!initialized || paused) return; // Reverse-calculate chartStartDspTime so that song-time now equals the music clip position // PLUS the constant lead (globalDelaySeconds/audio offset) captured at SetMusicAnchor. // Without adding musicToChartOffsetSeconds, resuming would force songTime == musicTime and // reintroduce the exact backward offset the SetMusicAnchor fix removed. double now = AudioSettings.dspTime; double targetSongTime = actualMusicTime + musicToChartOffsetSeconds; chartStartDspTime = now - accumulatedPauseSeconds - (targetSongTime - chartStartSongTime); nowSongTimeFrame = -1; } public static float NowSongTime { get { if (!initialized) { return 0f; } // While paused the value is frozen (pauseStartDspTime), so it's trivially stable; // while playing, reuse the value already computed this frame. int frame = Time.frameCount; if (frame == nowSongTimeFrame) { return nowSongTimeCached; } double currentDsp = paused ? pauseStartDspTime : AudioSettings.dspTime; double elapsed = currentDsp - chartStartDspTime - accumulatedPauseSeconds; float result = chartStartSongTime + Mathf.Max(0f, (float)elapsed); nowSongTimeFrame = frame; nowSongTimeCached = result; return result; } } public static float SongTimeFromDsp(double dspTime) { if (!initialized) { return chartStartSongTime; } double effectivePause = accumulatedPauseSeconds; if (paused && dspTime > pauseStartDspTime) { dspTime = pauseStartDspTime; } double elapsed = dspTime - chartStartDspTime - effectivePause; return chartStartSongTime + Mathf.Max(0f, (float)elapsed); } public static float ToAbsoluteChartTime(float songTimelineTimeSeconds) { return chartStartSongTime + songTimelineTimeSeconds; } public static System.Collections.IEnumerator WaitForSeconds(float seconds) { if (seconds <= 0f) { yield break; } if (!initialized) { yield return new UnityEngine.WaitForSeconds(seconds); yield break; } float targetTime = NowSongTime + seconds; while (NowSongTime < targetTime) { yield return null; } } }