Skip to content

Instantly share code, notes, and snippets.

@atcarter714
Created June 29, 2022 20:08
Show Gist options
  • Save atcarter714/6c17fc0e7eb27fc965d3c5d6210e74af to your computer and use it in GitHub Desktop.
Save atcarter714/6c17fc0e7eb27fc965d3c5d6210e74af to your computer and use it in GitHub Desktop.
A handy timer system for Unity utilizing the SOLID principles
#region Using Directives
using System;
using UnityEngine;
using UnityEngine.Events;
#endregion
/// <summary>
/// A simple countdown mechanism script
/// </summary>
/// <remarks>
/// Triggers an event when countdown is complete.
/// An additional UnityEvent is supplied to assign
/// listeners in the editor without code.
/// </remarks>
public class CountdownBehaviour: MonoBehaviour, ICountdown
{
#region Editor Properties
[Space, Tooltip( "Indicates if countdown should automatically start when enabled" )]
[SerializeField] protected bool autoStart = true;
[Space, Tooltip( "The countdown timern object settings" )]
[SerializeField] protected TimerCountdown countdown = new TimerCountdown();
[Space( 32f )]
[Header( "Editor-assigned Events:" )]
[Tooltip( "Additional editor-assigned events" )]
[SerializeField] protected UnityEvent timedOutEvent = new UnityEvent();
#endregion
#region ICountdown Implementation
public virtual float TimeRemaining => countdown.TimeRemaining;
public virtual bool IsRunning => countdown.IsRunning;
public virtual float ElapsedTime => countdown.ElapsedTime;
public virtual bool AutoRestart {
get => countdown.AutoRestart;
set => countdown.AutoRestart = value;
}
public virtual float WaitTime {
get => countdown.WaitTime;
set => countdown.WaitTime = value;
}
public virtual event Action TimedOut {
add => countdown.TimedOut += value;
remove => countdown.TimedOut -= value;
}
public virtual void Clear() => countdown.Clear();
public virtual void Run() => countdown.Run();
public virtual void Stop() => countdown.Stop();
public virtual float Tick( float dt ) => countdown.Tick( dt );
#endregion
/// <summary>
/// Executed when the internal countdown fires
/// </summary>
/// <remarks>
/// Runs any UnityEvents assigned in the editor
/// </remarks>
protected virtual void onTimedOut() => timedOutEvent?.Invoke();
#region Unity Messages
void Awake() {
if ( countdown is null )
countdown = new TimerCountdown();
}
void OnEnable() {
countdown.TimedOut += onTimedOut;
if ( autoStart ) Run();
}
void OnDisable() {
Stop();
countdown.TimedOut -= onTimedOut;
}
void Update() {
if ( countdown.IsRunning )
countdown.Tick( Time.deltaTime );
}
#endregion
}
#region Using Directives
using System;
using UnityEngine;
#endregion
/* NOTES:
* Implementation of a MonoBehaviour Component version of our
* Timer object which can be attached a GameObject and used like
* any other Component. We cannot inherent from both Timer and
* MonoBehaviour, but thanks to our interfaces we don't need to!
* We simply use containment, not inheritance, of a Timer object
* and then implement the interface. Our interface implementation
* just wraps the identical features found in Timer and works the
* exact same way. External objects can deal with either form of
* timers and doesn't care if its a Component or not ...
*/
/// <summary>
/// A simple Timer mechanism script
/// </summary>
/// <remarks>
/// Attaches to a GameObject and counts elapsed
/// time with stopwatch style functionality
/// </remarks>
public class TimerBehaviour: MonoBehaviour, IStopwatch
{
[SerializeField] protected Timer timer = new Timer();
#region IStopwatch Implementation
public virtual bool IsRunning => timer.IsRunning;
public virtual float ElapsedTime => timer.ElapsedTime;
public virtual void Clear() => timer.Clear();
public virtual void Run() => timer.Run();
public virtual void Stop() => timer.Stop();
public virtual float Tick( float dt ) => timer.Tick( dt );
#endregion
void Awake() {
if( timer is null )
timer = new Timer();
}
void Update()
{
if ( timer.IsRunning )
timer.Tick( Time.deltaTime );
}
}
#region Using Directives
using System;
using UnityEngine;
#endregion
/* NOTES:
* We're defining the interface or public contract of all simple
* timers and countdowns which will be common for all such types
* regardless of any special/specific implementations. This allows
* us to write code that will interact with such objects that isn't
* dependent on some specific type or version ...
*/
/// <summary>
/// Simple interface of a time-tracking object
/// </summary>
public interface IStopwatch
{
/// <summary>
/// Indicates if the Timer object is running
/// </summary>
bool IsRunning { get; }
/// <summary>
/// Amount of time that has elapsed on timer
/// </summary>
float ElapsedTime { get; }
/// <summary>
/// Resets the Timer
/// </summary>
/// <remarks>
/// Timer is stopped and elapsed time is zero.
/// </remarks>
void Clear();
/// <summary>
/// Runs the Timer
/// </summary>
void Run();
/// <summary>
/// Stops the Timer
/// </summary>
/// <remarks>
/// Elapsed time no longer counts even if
/// Tick is called
/// </remarks>
void Stop();
/// <summary>
/// Updates the Timer and ticks elapsed time
/// </summary>
/// <param name="dt">Elapsed time to add</param>
/// <returns>Total elapsed time counted</returns>
float Tick( float dt );
};
/// <summary>
/// Interface for an object that counts down
/// and triggers an action/event
/// </summary>
public interface ICountdown: IStopwatch
{
/// <summary>
/// Indicates if Timer shall automatically restart
/// </summary>
bool AutoRestart { get; set; }
/// <summary>
/// Amount of time the timer will wait before
/// executing a particular action
/// </summary>
float WaitTime { get; set; }
/// <summary>
/// Amount of time remaining until time is up
/// </summary>
float TimeRemaining { get; }
/// <summary>
/// Event triggered when the timer is finished waiting
/// for the specified length of time
/// </summary>
event Action TimedOut;
};
/* NOTES:
* These are two simple base classes defining a concrete
* base type for a simple timer and a countdown timer which
* will raise an event. These implement our interfaces defined
* above. MonoBehaviours can then use these classes and implement
* the same interfaces themselves, allowing other code to interact
* with instances of either the regular classes or the MonoBehaviour
* Component versions without depending on our specific implementations.
*/
/// <summary>
/// Simple Stopwatch for counting time
/// </summary>
[Serializable] public class Timer: IStopwatch
{
/// <summary>
/// Indicates if the Timer object is running
/// </summary>
[field: SerializeField]
public virtual bool IsRunning { get; protected set; } = false;
/// <summary>
/// Amount of time that has elapsed on timer
/// </summary>
[field: SerializeField]
public virtual float ElapsedTime { get; protected set; } = 0f;
/// <summary>
/// Resets the Timer
/// </summary>
/// <remarks>
/// Timer is stopped and elapsed time is zero.
/// </remarks>
public virtual void Clear() {
IsRunning = false;
ElapsedTime = 0.0f;
}
/// <summary>
/// Runs the Timer
/// </summary>
public virtual void Run() => IsRunning = true;
/// <summary>
/// Stops the Timer
/// </summary>
/// <remarks>
/// Elapsed time no longer counts even if
/// Tick is called
/// </remarks>
public virtual void Stop() => IsRunning = false;
/// <summary>
/// Updates the Timer and ticks elapsed time
/// </summary>
/// <param name="dt">Elapsed time to add</param>
/// <returns>Total elapsed time counted</returns>
public virtual float Tick( float dt ) {
if ( IsRunning ) ElapsedTime += dt;
return ElapsedTime;
}
}
/// <summary>
/// Base class for a simple countdown timer
/// </summary>
[Serializable] public class TimerCountdown : Timer, ICountdown
{
#region Constant & Static Read-only Values
public const float _MAXIMUM_WAITTIME = 21600f;
public const float _DEFAULT_WAITTIME = 5.0f;
#endregion
protected object _lock = new();
protected event Action _timedOut;
/// <summary>
/// Creates a new TimerCountdown
/// </summary>
/// <remarks>
/// Initialized with the default wait time.
/// </remarks>
public TimerCountdown() { }
/// <summary>
/// Creates a new TimerCountdown with the specified target time
/// </summary>
/// <param name="waitTime">Amount of time to wait</param>
/// <exception cref="ArgumentOutOfRangeException">
/// Negative length of time was given.
/// </exception>
public TimerCountdown( float waitTime )
{
#if UNITY_EDITOR || DEBUG
if ( waitTime <= 0 ) {
throw new ArgumentOutOfRangeException(
"Wait time must be a positive, non-zero value!" );
}
#endif
WaitTime = waitTime;
}
/// <summary>
/// Indicates if Timer shall automatically restart
/// </summary>
[field: SerializeField]
public virtual bool AutoRestart { get; set; } = false;
/// <summary>
/// Amount of time the timer will wait before
/// executing a particular action
/// </summary>
[field: SerializeField, Range( 0f, _MAXIMUM_WAITTIME )]
public virtual float WaitTime { get; set; } = _DEFAULT_WAITTIME;
/// <summary>
/// Amount of time remaining until time is up
/// </summary>
public virtual float TimeRemaining => ( WaitTime - ElapsedTime );
/// <summary>
/// Event triggered when the timer is finished waiting
/// for the specified length of time
/// </summary>
public virtual event Action TimedOut {
add {
lock( _lock )
_timedOut += value;
}
remove {
lock ( _lock )
_timedOut -= value;
}
}
/// <summary>
/// Updates the Timer and ticks elapsed time
/// </summary>
/// <param name="dt">Elapsed time to add</param>
/// <returns>Total elapsed time counted</returns>
public override float Tick( float dt ) {
if( base.Tick( dt ) >= WaitTime )
onTimedOut();
return ElapsedTime;
}
/// <summary>
/// Executed when countdown is complete
/// </summary>
protected internal virtual void onTimedOut() {
_timedOut?.Invoke();
Clear();
if ( AutoRestart ) Run();
}
}
@atcarter714
Copy link
Author

atcarter714 commented Jun 29, 2022

The concept here is that we create a timer object that can fire an event and perform any arbitrary action we choose at a specified interval, and we do so in a way that we can use this thing as either a regular old object or as an actual Unity Component we can attach to a GameObject. Using the SOLID principles allows us to this in such a way where code can be written to manipulate timers no matter which implementation is used. For example, if we needed to pause all timers when the game is paused or an inventory menu is open, we can have a list of every single instance of these objects all in one place and start/stop them as needed. C# may not allow multiple inheritance, but this shows one way you can effectively create the same capabilities as a C++ class without actual multiple inheritance.

EDIT: Forgot to mention, some things here are still marked as virtual in these classes that probably shouldn't be, but I left things open to overrides while I was inventing new classes and ideas. If you want to use this, I'd recommend taking the virtual modifier off of things you're not actually going to override.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment