Skip to content

Sequence Programming (Coroutine)

We have mastered automated and simple events with the Cube Factory. Now, let’s create more create complex sequence flows and dive into coroutines!

In the context of Unity, a coroutine is the name for a function that is started/stopped according to specific instructions. For instance, it can be "wait for 2 seconds" or "wait until the participant has pressed a button". The function is stopped at the wait statement and is resumed when the clause has been met.

Coroutine functions in Unity are very easy to identify: they return a value of type IEnumerator and must use the yield keyword. The coroutine function below simply waits for 2 seconds.

private IEnumerator MyCoroutine()
{
    print("Before wait");
    yield return new WaitForSeconds(2f);
    print("After wait");
}

A coroutine is called using the StartCoroutine function.

private void Start()
{
    StartCoroutine(MyCoroutine());
}

Setting Up the Scene

For this next exercise you can continue working in the same project as the Cube Factory whoch is already set up for VR. Simply create a new scene to work on and name it "sequenceProg".

If you choose to create a whole new project make sure you follow the instruction at the start of exercise IV to set up a Unity project for VR.

Archiving and Copying Unity Projects - click to read Another way to continue working without sacrificing already completed projects is to make a copy of an entire Unity project in your file explorer. For example, go to the Unity Hub, right-click on your project to "Show in Finder/Explorer," and copy its entire folder. You can rename the copied folder as you wish, then return to the Hub and click Open instead of New Project, and point it to your newly copied folder.
Remember to not copy the folder named "library", it contains cached processed data generated from your asset folder. It contains tens of thousands of files and can easily weight more than 10GB. It is fine not to copy it because Unity will regenerate it.

We will be creating a few new objects again for our scene.

Create two new 3D Plane objects as children of the ground plane object and configure them as follows:

  • "StandingMark"
    • Position: (-2.333, .001, -2.333)
    • Scale: (.1, 1, .1)
  • "OriginMark"
    • Position (0, .001, 0)
    • Echelle (.1, 1, .1)

Create a material with a blue color named BlueBox and one with a red color named RedBox. Assign the BlueBox Material to StandingMark, and the RedBox Material to OriginMark.

Outside of the ground object, in the empty spaces of the hierarchy of our scene, create:

  • A 3D object Sphere and name it "Zeppelin":
    • Position (-1, 1.5, -1)
    • Rotation (0, 90, 0)
    • Scale (.5, .2, .2)
  • A new Cube :
    • Position (0, 1.5, 1)
    • Scale (.2, .1, .2)

Interacting with objects

Attach our existing IsCollidingChecker script created earlier (exercise V) as a component to the new Sphere (Zeppelin) and the new Cube: Click on Add Component and start typing its name.

Give the IsCollidingChecker components that you just attached to the Cube and the Zeppelin two new colors of your choice. They can be different to spice things up!

Moving the Objects

You should now have the last two objects simply floating in the air. Let’s write new scripts to make them move.

Create a script called Moving and attach it to the Zeppelin object:

Moving.cs
using UnityEngine;

public class Moving : MonoBehaviour
{
    [Tooltip("Units per second")] public float speed;
    [Tooltip("Start position in 3D space")] public Vector3 startPoint;
    [Tooltip("End position in 3D space")] public Vector3 endPoint;

    public float interpolator = 1f;
    public bool isDone => interpolator > .999f;

    void Update()
    {
        print(isDone);
        if (isDone) return;

        interpolator += speed * Time.deltaTime;
        transform.position = Vector3.Lerp(startPoint, endPoint, interpolator);
    }
}
It takes speed and two Vector3 variables for its movement speed (via interpolation) and for the starting and ending points of its journey.

isDone?

The expression isDone => interpolator > .999f is a handy shorthand: the operator => assigns to isDone the result of evaluating interpolator > .999f, similar to what an if statement would do. You can read more about this in the C# documentation.

What this effectively does is constantly check if interpolator is greater than .999f, and sets isDone to true if it is, and false if it is not.

Since interpolator is already 1 at the start, it will set isDone to true, which will cancel the execution of Update(). It will need to be set to a smaller value to start functioning.

This function also prints the state of isDone in the Console at each update using the print() command: you can see it when the program is running, as it will quickly fill the console with prints and continue to scroll down.

Printing to the Console

It can be wise to display the state of variables in the console to have a clear understanding of what is happening. Doing it as above (printing at each Update()) is one way, but generally, this is only used at specific events, such as when a variable is modified. Using print() — or the Unity equivalent Debug.Log() — is a powerful tool for understanding and debugging your code, so feel free to try it on other variables and in other places in your code yourself!

Set speed in the inspector to 0.25, and give (-1, 1.5, -1) as the starting point and (-1, 1.5, 1) as the endpoint for its trajectory.

Now, create a script called Rotating for the new cube object:

Rotating.cs
using UnityEngine;

public class Rotating : MonoBehaviour
{
    [Tooltip("Units per second")] public float speed;
    [Tooltip("Axis to rotate around")] public Vector3 axis;

    public bool isRotating;

    void Update()
    {
        if (!isRotating) return;

        transform.Rotate(axis, speed * Time.deltaTime);
    }
}

Very similar to our first rotation script, it only differs in having a public boolean called isRotating that is checked before performing the rotation — it essentially acts as an on/off switch.

Set the speed to 180 and the rotation axis to (1, 0, 0) in the inspector.

If you run the game now, only the cube object should rotate, and only if you set its isRotating parameter to true in its Rotating component in the inspector. Try it out, and play with the parameters in the inspector to see their effects:

Protocol Script

With the two objects moving and the two areas to walk on, we have set the stage to introduce more complex flows, such as those that might be needed for real experiments (or even games).

Variables and Assignments

Create a new script for the Main Camera object and name it Protocol:

Protocol.cs
using System.Collections;
using UnityEngine;

public class Protocol : MonoBehaviour
{
    public Moving movingComp;
    public Rotating rotatingComp;

    public IsCollidingChecker zeppelinColliderChecker;
    public IsCollidingChecker cubeColliderChecker;

    public Transform headCamera;
    public Transform standingMark;

    public float positionTolerance = 0.15f;

    private bool isRunning = true;
}

At this point, nothing in this first part of the declaration should be unknown: Protocol.cs contains a number of public variables, four object components, two transforms, a floating-point number, and a boolean.

Save the script as it is so far, and ensure it is attached to our "Main Camera." Return to the Unity editor to assign the unfilled variables in the inspector.

Can you figure out how to correctly fill in the fields in the inspector for the Protocol component of the "Main Camera"? The expected names and types should make this easy.

Continue only when you are sure you have set it up correctly. Feel free to ask if you encounter difficulties here.

Interaction Functions

Let’s give ourselves the ability to interact with Protocol.cs in the VR world. Add these functions:

Protocol.cs
private void Update()
{
    if (button.ReadWasCompletedThisFrame() && isRunning)
    {
        isRunning = false;
    }
}

private bool IsStandingOnTarget(Vector2 targetPos)
{
    Vector3 pos3D = headCamera.position;
    Vector2 standingPos = new Vector2(pos3D.x, pos3D.z);

    return Vector2.Distance(standingPos, targetPos) < positionTolerance;
}

Use the method described in the section IV under the subsection "Means of Interaction" > "By Programming" so that the button variable references the state of a button on one of the controllers.

The Update() loop should be clear: if the button is pressed AND isRunning is already true, THEN set the isRunning variable to false.

IsStandingOnTarget(Vector2 targetPos) takes a given target position (a 2D vector) and measures its distance to a 2D projection of the headCamera — if it is less than the positionTolerance, it returns true; otherwise, it returns false.

Scripted Sequence

Now let’s put these variables and functions to work.

We will use the Start() call for this, but first, change it from a basic private void function; we will transform it into an IEnumerator — this way, it acts like a coroutine, and we can stop and continue its execution using WaitWhile(), WaitUntil(), and WaitForSecondsRealtime() commands.

Replace the current Start() function with this:

Protocol.cs
private IEnumerator Start()
{
    while (isRunning)
    {
        // Wait until user has moved onto square on the floor
        Vector3 standingMarkPos = standingMark.position;
        yield return new WaitWhile(() => !IsStandingOnTarget(new Vector2(standingMarkPos.x, standingMarkPos.z)));
        print("Stepped on the first square");
        standingMark.gameObject.SetActive(false); // Hide

        // Wait until user has touched the zeppelin
        yield return new WaitUntil(() => zeppelinColliderChecker.isColliding);
        print("Touched the Zeppelin");

        movingComp.interpolator = 0; // This triggers the start of Zeppelin's animation
        // Wait for moving animation to end
        yield return new WaitUntil(() => movingComp.isDone);
        print("Zeppelin's animation is done");

        // Move to center of the room
        yield return new WaitWhile(() => !IsStandingOnTarget(Vector2.zero));
        print("Stepped on the center square");

        // Wait until user has touched the cube
        yield return new WaitUntil(() => cubeColliderChecker.isColliding);
        rotatingComp.isRotating = true; // Start rotating cube
        print("Touched the cube");

        // Wait one second while it rotates
        yield return new WaitForSecondsRealtime(1f);
        rotatingComp.isRotating = false; // Stop rotating cube
        print("One second has passed");

        // RESET everything
        standingMark.gameObject.SetActive(true); // Show
        movingComp.transform.position = movingComp.startPoint;
        rotatingComp.transform.rotation = Quaternion.identity;
        print("Everything's reset!");
    }
}

You should be able to read this script and understand what it does. The different lines yield return new interrupt the execution of the function (stopping progress to the following lines) until their Wait clauses are fulfilled, as explained in the comments for each line.

Save the script, return to Unity, and ensure that the cube is not already set to rotate.

Run the game and try to progress through the different steps as you can see in the protocol!

Challenge: writing a trial sequence

With the knowledge of writing coroutines you can now write any sequence of events that would be a trial sequence in one of your experiments.

Can you think of one to write as a challenge?

If not, here is an example, use what we learned in exercise III to create a protocol that would go as follows:

  1. The participants starts in an empty room,
  2. A press of a button on a controller triggers the start of the experiment,
  3. A floating objects appears somewhere around the participant,
  4. They must touch it to continue (forces the participants to orient themselves in a predetermined direction)
  5. An image appears in front of the participant,
  6. After 4 seconds the image disappears, replaced by two panels (quads) with "Yes" and "No" written on them,
  7. The participants touches one panel to give their answer,
  8. Wait for 1 second (inter-trial interval),
  9. Go back to step #3.