gif_ceraf

The Ceraf enemy uses timers to control its pre-shoot, shoot and post-shoot animations and actions.

Warning: Super dry, tool-focused devlog incoming!

One of the tasks we find ourselves doing quite frequently while working on Lovers is controlling the timing of things (loop an animation for x seconds, randomize AI behaviour every y seconds, etc.). There are many ways to accomplish these types of actions, for instance you could do something like this:

float timerLength = 1;

void Update()
{
  if(timerLength > 0)
  {
    timerLength -= Time.deltaTime;
    if(timerLength <= 0)     {       doSomeAction();     }   } }

This works in the simplest cases, but it requires you to reuse the same code in any script that uses a timer and quickly becomes cumbersome if you require multiple timers in a script.

You could use a coroutine with Unity’s handy built-in wait function:

void Start()
{
  StartCoroutine(timerCoroutine());
}

IEnumerator timerCoroutine()
{
  yield return new WaitForSeconds(1);
  doSomeAction();
}

Coroutines are great for when you want a long sequence of actions or when you need to do something over a number of frames, but if you just want to wait for a certain amount time before performing an action, having to write a new method is tedious (plus, until recently they were very difficult to cancel).

To get around these issues and help satisfy our laziness we created a class that to encapsulate the functionality of a countdown timer: CoroutineTimer.

using UnityEngine;
using System.Collections;
[System.Serializable]
public class CoroutineTimer
{
//public fields
public float TimerDuration, TimerRandomScaleFactor, TimerStartDelay;
public bool Repeats;
//private fields
bool _running;
bool startDelayComplete;
CoroutineTimerBehaviour timerBehaviour;
System.Action timerFinishedAction;
public CoroutineTimer(float timerDuration, float timerRandomScaleFactor,
float timerStartDelay, bool repeats)
{
this.TimerDuration = timerDuration;
this.TimerRandomScaleFactor = timerRandomScaleFactor;
this.TimerStartDelay = timerStartDelay;
this.Repeats = repeats;
}
public CoroutineTimer(float timerDuration) : this(timerDuration, 0, 0, false)
{
}
public void Start(GameObject targetGameObject, System.Action timerFinishedAction)
{
if(timerBehaviour != null)
{
throw new System.InvalidOperationException(
"This timer has already been started");
}
timerBehaviour = targetGameObject.AddComponent<CoroutineTimerBehaviour>();
this.timerFinishedAction = timerFinishedAction;
//if we have a start delay, run a timer for that before starting the real timer
if(TimerStartDelay != 0.0f && !startDelayComplete)
{
timerBehaviour.StartTimer(TimerStartDelay, startTimerFinished);
}
else
{
doStart();
}
}
void doStart()
{
if(TimerDuration == 0.0f)
{
throw new System.InvalidOperationException("Timer duration cannot be zero");
}
if(TimerRandomScaleFactor < 0.0f || TimerRandomScaleFactor > 1.0f)
{
throw new System.ArgumentException(
"Timer scale factor must be between 0 and 1");
}
float waitSeconds = Random.Range(TimerDuration * (1 - TimerRandomScaleFactor),
TimerDuration * (1 + TimerRandomScaleFactor));
timerBehaviour.StartTimer(waitSeconds, timerFinished);
_running = true;
}
public void Stop()
{
_running = false;
//stop coroutines, remove timer component and clean up references
if(timerBehaviour != null)
{
timerBehaviour.StopCoroutine(CoroutineTimerBehaviour.TimerCoroutineName);
GameObject.Destroy(timerBehaviour);
}
timerBehaviour = null;
timerFinishedAction = null;
}
void startTimerFinished()
{
startDelayComplete = true;
doStart();
}
void timerFinished()
{
_running = false;
if(timerFinishedAction != null)
{
timerFinishedAction();
}
//null check to make sure the timer has not been stopped in the timerFinishedDelegate
if(Repeats && timerBehaviour != null)
{
doStart();
}
else
{
Stop();
}
}
#region Property methods
public bool Running
{
get
{
return _running;
}
}
#endregion
}
class CoroutineTimerBehaviour : MonoBehaviour
{
public static readonly string TimerCoroutineName = "startTimer";
System.Action timerFinishedAction;
public void StartTimer(float waitSeconds, System.Action timerFinishedAction)
{
this.timerFinishedAction = timerFinishedAction;
StartCoroutine(TimerCoroutineName, waitSeconds);
}
IEnumerator startTimer(float waitSeconds)
{
yield return new WaitForSeconds(waitSeconds);
timerFinishedAction();
}
}

As its name implies, CoroutineTimer utilizes Unity’s coroutine library to provide a straightforward timer mechanism. In its simplest form, CoroutineTimer acts a straightforward timer:

CoroutineTimer timer = new CoroutineTimer(timerLength);
timer.Start(gameObject, doSomeAction);

After timerLength seconds, the doSomeAction method will be called. (Note that you must supply a GameObject to the timer’s Start method as coroutines can only be run by MonoBehaviours and CoroutineTimer attaches a new MonoBehaviour to the supplied GameObject when it runs.)

We’ve also included additional functionality to CoroutineTimer that comes in handy relatively frequently. For instance, say you wanted an enemy to shoot every 2 seconds, but to only start shooting initially after 4.5 seconds have passed. Also, you realize that it looks pretty mechanical if the enemy shoots *exactly* every 2 seconds, so you want to randomize the behaviour a bit so it actually only shoots every 1.8-2.2 seconds (i.e. a 10% randomization). Sure thing, no problem:

float length = 2f;
float randomizationFactor = 0.1f;
float startDelay = 4.5f;
bool repeat = true;
CoroutineTimer timer = new CoroutineTimer(length, randomizationFactor, startDelay, repeat);
timer.Start(gameObject, shoot);

CoroutineTimers can also be cancelled at any point (using the Stop() method) or reused once they are stopped finished. Additionally, it uses the [System.Serializable] attribute, so its properties can be serialized and exposed in the Unity Editor.

coroutine-timer-serialized

Limitations & peculiarities

Unfortunately there is no way to check how much time is left in a CoroutineTimer, you merely start it and it lets you know when it’s done.

CoroutineTimer uses the string-based method of starting and stopping its coroutines. We’re generally not big fans of this approach, but until Unity 4.5 it was the only way to easily cancel a running coroutine. Now that the StopCoroutine method can take an IEnumerator, we will likely update the class in the future to use that instead. This change would also negate the need to pass a GameObject to the timer (we are attaching a new MonoBehaviour for the timer only for safety since StopCoroutine(someString) stops all coroutine methods named someString on a given MonoBehaviour) and we could instead simply pass a MonoBehaviour.