using System; using System.Collections.Generic; using UnityEngine; using Object = UnityEngine.Object; /// /// Allows you to run events on a delay without the use of s /// or s. /// /// To create and start a Timer, use the method. /// namespace GameCore { public class Timer { #region Public Properties/Fields /// /// How long the timer takes to complete from start to finish. /// public float duration { get; private set; } /// /// Whether the timer will run again after completion. /// public bool isLooped { get; set; } /// /// Whether or not the timer completed running. This is false if the timer was canceled. /// public bool isCompleted { get; private set; } /// /// Whether the timer uses real-time or game-time. Real time is unaffected by changes to the timescale /// of the game(e.g. pausing, slow-mo), while game time is affected. /// public bool usesRealTime { get; private set; } /// /// Whether the timer is currently paused. /// public bool isPaused { get { return _timeElapsedBeforePause.HasValue; } } /// /// Whether or not the timer was canceled. /// public bool isCancelled { get { return _timeElapsedBeforeCancel.HasValue; } } /// /// Get whether or not the timer has finished running for any reason. /// public bool isDone { get { return isCompleted || isCancelled || isOwnerDestroyed; } } #endregion #region Public Static Methods public static void Create() { // create a manager object to update all the timers if one does not already exist. if (_manager != null) { Debug.LogError("[UnityTimer]Create: TimerManager already exist, ignore this call!"); return; } Debug.Log($"[UnityTimer]Create"); var managerObject = new GameObject { name = nameof(TimerMgr) }; Object.DontDestroyOnLoad(managerObject); _manager = managerObject.AddComponent(); } public static void Destroy() { Debug.Log($"[UnityTimer]Destroy"); if (_manager != null) { GameObject.Destroy(_manager.gameObject); _manager = null; } } /// /// Register a new timer that should fire an event after a certain amount of time /// has elapsed. /// /// Registered timers are destroyed when the scene changes. /// /// The time to wait before the timer should fire, in seconds. /// An action to fire when the timer completes. /// An action that should fire each time the timer is updated. Takes the amount /// of time passed in seconds since the start of the timer's current loop. /// Whether the timer should repeat after executing. /// Whether the timer uses real-time(i.e. not affected by pauses, /// slow/fast motion) or game-time(will be affected by pauses and slow/fast-motion). /// An object to attach this timer to. After the object is destroyed, /// the timer will expire and not execute. This allows you to avoid annoying s /// by preventing the timer from running and accessing its parents' components /// after the parent has been destroyed. /// A timer object that allows you to examine stats and stop/resume progress. public static Timer Register(float duration, Action onComplete, Action onUpdate = null, bool isLooped = false, bool useRealTime = false, MonoBehaviour autoDestroyOwner = null) { if (_manager == null) { Debug.LogError("[UnityTimer]Register: TimerManager doesn't exist yet!"); return null; } Timer timer = new Timer(duration, onComplete, onUpdate, isLooped, useRealTime, autoDestroyOwner); _manager.RegisterTimer(timer); return timer; } /// /// Cancels a timer. The main benefit of this over the method on the instance is that you will not get /// a if the timer is null. /// /// The timer to cancel. public static void Cancel(Timer timer) { timer?.Cancel(); } /// /// Pause a timer. The main benefit of this over the method on the instance is that you will not get /// a if the timer is null. /// /// The timer to pause. public static void Pause(Timer timer) { timer?.Pause(); } /// /// Resume a timer. The main benefit of this over the method on the instance is that you will not get /// a if the timer is null. /// /// The timer to resume. public static void Resume(Timer timer) { timer?.Resume(); } public static void CancelAllRegisteredTimers() { if (_manager != null) { _manager.CancelAllTimers(); } // if the manager doesn't exist, we don't have any registered timers yet, so don't // need to do anything in this case } public static void PauseAllRegisteredTimers() { if (_manager != null) { _manager.PauseAllTimers(); } // if the manager doesn't exist, we don't have any registered timers yet, so don't // need to do anything in this case } public static void ResumeAllRegisteredTimers() { if (_manager != null) { _manager.ResumeAllTimers(); } // if the manager doesn't exist, we don't have any registered timers yet, so don't // need to do anything in this case } #endregion #region Public Methods /// /// Stop a timer that is in-progress or paused. The timer's on completion callback will not be called. /// public void Cancel() { if (isDone) { return; } _timeElapsedBeforeCancel = GetTimeElapsed(); _timeElapsedBeforePause = null; } /// /// Pause a running timer. A paused timer can be resumed from the same point it was paused. /// public void Pause() { if (isPaused || isDone) { return; } _timeElapsedBeforePause = GetTimeElapsed(); } /// /// Continue a paused timer. Does nothing if the timer has not been paused. /// public void Resume() { if (!isPaused || isDone) { return; } _timeElapsedBeforePause = null; } /// /// Get how many seconds have elapsed since the start of this timer's current cycle. /// /// The number of seconds that have elapsed since the start of this timer's current cycle, i.e. /// the current loop if the timer is looped, or the start if it isn't. /// /// If the timer has finished running, this is equal to the duration. /// /// If the timer was cancelled/paused, this is equal to the number of seconds that passed between the timer /// starting and when it was cancelled/paused. public float GetTimeElapsed() { if (isCompleted || GetWorldTime() >= GetFireTime()) { return duration; } return _timeElapsedBeforeCancel ?? _timeElapsedBeforePause ?? GetWorldTime() - _startTime; } /// /// Get how many seconds remain before the timer completes. /// /// The number of seconds that remain to be elapsed until the timer is completed. A timer /// is only elapsing time if it is not paused, cancelled, or completed. This will be equal to zero /// if the timer completed. public float GetTimeRemaining() { return duration - GetTimeElapsed(); } /// /// Get how much progress the timer has made from start to finish as a ratio. /// /// A value from 0 to 1 indicating how much of the timer's duration has been elapsed. public float GetRatioComplete() { return GetTimeElapsed() / duration; } /// /// Get how much progress the timer has left to make as a ratio. /// /// A value from 0 to 1 indicating how much of the timer's duration remains to be elapsed. public float GetRatioRemaining() { return GetTimeRemaining() / duration; } #endregion #region Private Static Properties/Fields // responsible for updating all registered timers private static TimerMgr _manager; #endregion #region Private Properties/Fields private bool isOwnerDestroyed { get { return _hasAutoDestroyOwner && _autoDestroyOwner == null; } } private readonly Action _onComplete; private readonly Action _onUpdate; private float _startTime; private float _lastUpdateTime; // for pausing, we push the start time forward by the amount of time that has passed. // this will mess with the amount of time that elapsed when we're cancelled or paused if we just // check the start time versus the current world time, so we need to cache the time that was elapsed // before we paused/cancelled private float? _timeElapsedBeforeCancel; private float? _timeElapsedBeforePause; // after the auto destroy owner is destroyed, the timer will expire // this way you don't run into any annoying bugs with timers running and accessing objects // after they have been destroyed private readonly MonoBehaviour _autoDestroyOwner; private readonly bool _hasAutoDestroyOwner; #endregion #region Private Constructor (use static Register method to create new timer) private Timer(float duration, Action onComplete, Action onUpdate, bool isLooped, bool usesRealTime, MonoBehaviour autoDestroyOwner) { this.duration = duration; _onComplete = onComplete; _onUpdate = onUpdate; this.isLooped = isLooped; this.usesRealTime = usesRealTime; _autoDestroyOwner = autoDestroyOwner; _hasAutoDestroyOwner = autoDestroyOwner != null; _startTime = GetWorldTime(); _lastUpdateTime = _startTime; } #endregion #region Private Methods private float GetWorldTime() { return usesRealTime ? Time.realtimeSinceStartup : Time.time; } private float GetFireTime() { return _startTime + duration; } private float GetTimeDelta() { return GetWorldTime() - _lastUpdateTime; } private void Update() { if (isDone) { return; } if (isPaused) { _startTime += GetTimeDelta(); _lastUpdateTime = GetWorldTime(); return; } _lastUpdateTime = GetWorldTime(); try { _onUpdate?.Invoke(GetTimeElapsed()); } catch (Exception ex) { Debug.LogError($"[Timer]Update: onUpdate error! {ex.Message}"); } if (GetWorldTime() >= GetFireTime()) { try { _onComplete?.Invoke(); } catch (Exception ex) { Debug.LogError($"[Timer]Update: onComplete error! {ex.Message}"); } if (isLooped) { _startTime = GetWorldTime(); } else { isCompleted = true; } } } #endregion #region Manager Class (implementation detail, spawned automatically and updates all registered timers) /// /// Manages updating all the s that are running in the application. /// This will be instantiated the first time you create a timer -- you do not need to add it into the /// scene manually. /// private class TimerMgr : MonoBehaviour { public int TimerCount { get; private set; } private ulong _timerID; private Dictionary _timers = new Dictionary(); // buffer adding timers so we don't edit a collection during iteration private List _timersToAdd = new List(); // buffer removing timers private List _timersToRemove = new List(); public void RegisterTimer(Timer timer) { _timersToAdd.Add(timer); Debug.Log($"[TimerMgr]RegisterTimer: {TimerCount + _timersToAdd.Count} timers in total"); } public void CancelAllTimers() { Debug.Log($"[TimerMgr]CancelAllTimers"); var etor = _timers.GetEnumerator(); while (etor.MoveNext()) { var timer = etor.Current.Value; timer.Cancel(); } _timers.Clear(); _timersToAdd.Clear(); TimerCount = 0; } public void PauseAllTimers() { Debug.Log($"[TimerMgr]PauseAllTimers"); var etor = _timers.GetEnumerator(); while (etor.MoveNext()) { var timer = etor.Current.Value; timer.Pause(); } } public void ResumeAllTimers() { Debug.Log($"[TimerMgr]ResumeAllTimers"); var etor = _timers.GetEnumerator(); while (etor.MoveNext()) { var timer = etor.Current.Value; timer.Resume(); } } // update all the registered timers on every frame private void Update() { if (_timersToAdd.Count > 0) { for (int i = 0; i < _timersToAdd.Count; i++) { _timers.Add(_timerID++, _timersToAdd[i]); } _timersToAdd.Clear(); } _timersToRemove.Clear(); var etor = _timers.GetEnumerator(); while (etor.MoveNext()) { var timer = etor.Current.Value; timer.Update(); if (timer.isDone) { _timersToRemove.Add(etor.Current.Key); } } if (_timersToRemove.Count > 0) { for (int i = 0; i < _timersToRemove.Count; i++) { _timers.Remove(_timersToRemove[i]); } } TimerCount = _timers.Count; } } #endregion } public static class TimerExtensions { /// /// Attach a timer on to the behaviour. If the behaviour is destroyed before the timer is completed, /// e.g. through a scene change, the timer callback will not execute. /// /// The behaviour to attach this timer to. /// The duration to wait before the timer fires. /// The action to run when the timer elapses. /// A function to call each tick of the timer. Takes the number of seconds elapsed since /// the start of the current cycle. /// Whether the timer should restart after executing. /// Whether the timer uses real-time(not affected by slow-mo or pausing) or /// game-time(affected by time scale changes). public static Timer AttachTimer(this MonoBehaviour behaviour, float duration, Action onComplete, Action onUpdate = null, bool isLooped = false, bool useRealTime = false) { return Timer.Register(duration, onComplete, onUpdate, isLooped, useRealTime, behaviour); } } }