Coroutines: Parallel Execution and Experiment Protocols (adapt to no VR)
We have mastered automated, simple events with the Cube Factory. Now let's create more complex flows and dive deeper into coroutines!
Reuse previous Unity project
Let's keep using the project we created in 2•Interaction to keep the first-person navigation.
Archiving and copying Unity projects
Another way to continue work without sacrificing previously finished projects is to making a copy of a whole Unity project in your file explorer, e.g., by going to the Unity hub, right-clicking your project to "Show in Finder/Explorer" and copying its whole folder. You can rename the copied folder to anything you want, then go back to the Hub and clicking Open instead of new New Project, then point it to your newly copied folder.
Setting the scene
We create again a few new objects.
Inside the Environment Object of the Playground scene, create two new Plane 3D objects and configure them like this:
- "StandingMark"
- Position:
(-2.333, .01, -2.333)
- Scale:
(0.15, 1, 0.15)
- Position:
- "OriginMark"
- Position
(0, .01, 0)
- Scale
(0.15, 1, 0.15)
- Position
Assign StandingMark the old BlueBox material, and the RedBox material to OriginMark. You can also simply recreate them by following the steps again.
Outside the Environment object, at empty spaces in the hierarchy of our scene, create:
- A Sphere 3D object and name it "Zeppelin":
- Position
(-1, 1.5, 4)
- Rotation
(0, 90, 0)
- Scale
(0.5, 0.2, 0.2)
- Position
- A new Cube:
- Position
(0, 1.5, -1)
- Scale
(0.2, 0.1, 0.2)
- Position
Attach to the new Sphere (Zeppelin) and the new cube a new Script called RaycastHitChecker
as a component. It will be responsible for checking if the object is currently being hit by a raycast, e.g. from the camera, an change its object accordingly.
Write the following code into the script:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class RaycastHitChecker : MonoBehaviour
{
// Boolean variables to hold its current and previous hit state
// the currentHitState can be accessed publicly, but only changed privately
public bool currentHitState { get; private set; }
private bool previousHitState = false;
// a public array of colors to hold the colors for the hit and not hit states
public Color[] colors;
// a private reference to the material of the object
private Material _material;
void Start ()
{
// Set the current hit state to false at the beginning
currentHitState = false;
// Get the material of the object
_material = GetComponent<MeshRenderer> ().material;
// Set the color of the material to the first color in the array, if both exist
if (_material != null && colors.Length > 0)
_material.color = colors[0];
}
// a public method to update the hit state
public void UpdateHitState (bool hitState)
{
// Set the current hit state to the new hit state
currentHitState = hitState;
// Check if the current hit state is different from the previous hit state
if (currentHitState != previousHitState)
{
// If the current hit state is true, switch to the hit color
if (currentHitState)
SwitchColor (1);
// If the current hit state is false, switch to the not hit color
else
SwitchColor (0);
}
// Set the previous hit state to the current hit state
previousHitState = currentHitState;
}
private void SwitchColor (int index)
{
// Set the color of the material to the color at the index in the array
// check if the index is within the bounds of the array
if (index < colors.Length)
_material.color = colors[index];
}
}
Once that script is saved and attached to both objects, you can access it in their respective Inspectors and assign two new colors to each colors
arrays. Choose two fancy colors, and preferably different for each object!
Setting objects in motion
Now we should have the two last objects simply hovering in the air. Let's write new scripts to get them moving.
Create a script called Moving
and attach it to the Zeppelin object:
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 moving speed and for start and end points of its journey.
isDone?
The expression isDone => interpolator > .999f
is a handy shortcut: the =>
operator assigns to isDone
the result of evaluating interpolator > .999f
, similar to how an if
statement would do. You can read more about this at the C# documentation.
What this effectively does is to constantly check if the interpolator
is greater than .999f
, and setting isDone
to true
if so, and false
if not.
interpolator
being already 1
at the beginning will set isDone
to true
, thus aborting the execution of Update()
. It will need to be set to a smaller value to get it going.
This function also outputs the state of isDone
to the Console at each update using the print()
command: you can see it when the program is running, as it will be filling up the console with printouts quickly and keep scrolling down.
Printing to console
It can be a good practice to output the state of variables to the console to have a clear understanding of what is going on. Doing it as above (printing at each Update()
) is one way, but usually it is used only at specific events, like when a variable is changed. Using print()
— or the Unity equivalent Debug.Log()
— well is a powerful helper to understanding and debugging your code, so feel free to try it on other variables and other positions in your code by yourself!
Set speed
in the inspector to 0.25
, and give the original coordinates (look at the Transform!) as the start point and a different position as the end point for its trajectory.
Now create a script called Rotating
for the new cube object:
using UnityEngine;
public class Rotating : MonoBehaviour
{
[Tooltip("Units per second")] public float speed;
[Tooltip("Axis to rotate around")] public Vector3 axis;
public bool isRotating = true;
void Update()
{
if (!isRotating) return;
transform.Rotate(axis, speed * Time.deltaTime);
}
}
This script takes a speed
and an axis
Vector3 variable to define its rotation speed and axis. It also has a public isRotating
boolean variable to control if it should rotate or not, which is accessible from the outside.
Set the speed to 180
and the rotation axis to (1, 0, 0)
in the inspector.
If you run the game now, only the the cube object should be rotating, and then only if its isRotating
parameter is set to true in its Rotating component in the inspector. It should be on by default, as we've assigned it like this in the script above.
Try it out by pressing Play, and play around with the parameters in the inspector to see their effects:
Scripting a protocol
With the two moving objects and two areas to step on have set the stage to introduce more complex flows, such as might be needed for actual experiments (or even games).
Variables and assignments
Create a new script for the MainCamera object and name it Protocol
:
using System.Collections;
using UnityEngine;
public class Protocol : MonoBehaviour
{
public Moving movingComponent;
public Rotating rotatingComponent;
public RaycastHitChecker zeppelinHitChecker;
public RaycastHitChecker cubeHitChecker;
public Transform cameraTransform;
public Transform standingMark, originMark;
public float positionTolerance = 0.5f;
private bool isRunning = true;
}
At this point, nothing in this first part of the declaration should be unfamiliar: Protocol.cs
holds a number of public variables to store a SteamVR action, four components from other objects, two transforms, a floating point number, and a boolean.
Save the script as it is so far, and make sure it's attached to our MainCamera. Go back to the Unity editor to assign the unfilled variables in the inspector.
Challenge: assign it yourself!
Can you figure out yourself how to fill the fields in the inspector for the MainCamera's Protocol component correctly?
The names and expected types should make this easy.
Continue only after you're confident you set it up correctly. Don't hesistate to ask if you're having trouble here.
Remember: you can directly drag an object from the Hierarchy into one of these fields, and the correct component will be automatically chosen.
Interaction functions
Let's give us the ability to interact with Protocol.cs
within the VR world. Add these functions:
private bool IsStandingOnTarget (Vector2 targetPos)
{
Vector3 pos3D = cameraTransform.position;
Vector2 standingPos = new Vector2 (pos3D.x, pos3D.z);
return Vector2.Distance (standingPos, targetPos) < positionTolerance;
}
The Update()
loop should be clear: if the mouse button is pressed AND isRunning
is already true, THEN turn the variable isRunning
to false.
IsStandingOnTarget(Vector2 targetPos)
takes a given target position (a 2D vector) and measures its distance to a 2D projection of the cameraTransform
— if it's below the positionTolerance
, it return true, otherwise false.
The Clicker turns into a Looker
If you still have the old Clicker.cs
script attached to the MainCamera, we can now modify it to constantly cast a ray from the camera, not only when the mouse is clicked. We still keep the old functionality, but add much more.
Modify it to look like this:
using UnityEngine;
public class Clicker : MonoBehaviour
{
GameObject previouslyHitObject;
// Update is called once per frame
void Update ()
{
// constantly send out a raycast from the camera
RaycastHit hit;
Ray ray = Camera.main.ScreenPointToRay (Input.mousePosition);
if (Physics.Raycast (ray, out hit))
{
GameObject obj = hit.transform.gameObject;
// check if the hit GameObject has a RaycastHitChecker component
if (obj.GetComponent<RaycastHitChecker> () != null)
{
// Call the UpdateHitState method of the RaycastHitChecker component
obj.GetComponent<RaycastHitChecker> ().UpdateHitState (true);
}
// Check if the mouse button is pressed down
if (Input.GetMouseButtonDown (0))
{
Debug.Log ("Clicked on " + hit.transform.gameObject.name);
// Check if the GameObject has a Clickable component
if (obj.GetComponent<Clickable> () != null)
{
// Call the OnClick method of the Clickable component
obj.GetComponent<Clickable> ().OnClick ();
}
}
// if there was a previously hit object that is not the current hit object
if (previouslyHitObject != null && previouslyHitObject != obj)
{
// check if the previously hit object has a RaycastHitChecker component
if (previouslyHitObject.GetComponent<RaycastHitChecker> () != null)
{
// Call the RaycastHitChecker component and set its state to false
previouslyHitObject.GetComponent<RaycastHitChecker> ().UpdateHitState (false);
}
}
// remember the previously hit object
previouslyHitObject = obj;
}
else
{
// We are not hitting anything. Check if there was a previously hit object
if (previouslyHitObject != null)
{
// check if the previously hit object has a RaycastHitChecker component
if (previouslyHitObject.GetComponent<RaycastHitChecker> () != null)
{
// Call the RaycastHitChecker component and set its state to false
previouslyHitObject.GetComponent<RaycastHitChecker> ().UpdateHitState (false);
}
}
}
}
}
It now remembers the previously hit object, and if the current hit object is different from the previous one, it will set the previous one's hit state to false. This way, we can deactivate objects that are not being looked at any more, and do it in an economical way, as we don't need to constantly check if every object in the scene is still being hit.
Scripted flow
Let's now put these variables and functions to work.
We will use the Start()
call for that, but first change it from a basic private void
type function, we will turn it into an IEnumerator
— this way, it acts as a coroutine and we can stop and continue its execution using WaitWhile()
, WaitUntil()
, and WaitForSecondsRealtime()
commands.
Replace the current Start()
function with this:
private IEnumerator Start ()
{
// stop the cube from rotating it's not off yet
rotatingComponent.isRotating = false;
while (isRunning)
{
// Wait until user has moved onto square on the floor
Vector3 standingMarkPos = standingMark.position;
yield return new WaitWhile (() => !IsStandingOnTarget (standingMark));
print ("Stepped on the first square");
standingMark.gameObject.SetActive (false); // Hide
// Wait until user has touched the zeppelin
yield return new WaitUntil (() => zeppelinHitChecker.currentHitState);
print ("Looked at the Zeppelin");
movingComponent.interpolator = 0; // This triggers the start of Zeppelin's animation
// Wait for moving animation to end
yield return new WaitUntil (() => movingComponent.isDone);
print ("Zeppelin's animation is done");
// set the next target square to be visible
originMark.gameObject.SetActive (true);
// Move to center of the room
yield return new WaitWhile (() => !IsStandingOnTarget (originMark));
print ("Stepped on the origin square");
originMark.gameObject.SetActive (false); // Hide
// Wait until user has touched the cube
yield return new WaitUntil (() => cubeHitChecker.currentHitState);
rotatingComponent.isRotating = true; // Start rotating cube
print ("Looked at the cube");
// Wait one second while it rotates
yield return new WaitForSecondsRealtime (1f);
rotatingComponent.isRotating = false; // Stop rotating cube
print ("One second has passed");
// RESET everything
standingMark.gameObject.SetActive (true); // Show the target mark again
movingComponent.transform.position = movingComponent.startPoint;
rotatingComponent.transform.rotation = Quaternion.identity;
print ("Everything's reset!");
}
}
You should be able to read this script and understand what it's doing. The various yield return new
lines halt the execution of the function (stopping the advancement to the next lines) until their Wait
clauses are fulfilled, as explained in the comments for every line.
Save the script, go back to Unity, and make sure the cube is not set to rotate already.
Run the game, and try to advance through the different steps as you can see in the protocol! The console will print updates on the user's progress through the steps.
Trials and Quitting
The protocol repeats endlessly. Let's keep track of how many trials the user has completed, and have the application quit itself after a certain number of trials.
For that we need to change the Protocol.cs
a bit. First, add using UnityEditor;
to the top of the script, and then add two variables for managing the trials:
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
public class Protocol : MonoBehaviour
{
private int trialCounter = 0;
public int maxTrials = 3;
In the Inspector you can now set a maximum trial number, and the script will keep track of how many trials have been completed. For that to work, we will need to edit our experiment flow a bit.
The start function needs to be changed to this:
private IEnumerator Start ()
{
// stop the cube from rotating it's not off yet
rotatingComponent.isRotating = false;
while (isRunning)
{
// increment the trial counter
trialCounter++;
Debug.Log ("Trial " + trialCounter + " is starting");
// Wait until user has moved onto square on the floor
Vector3 standingMarkPos = standingMark.position;
yield return new WaitWhile (() => !IsStandingOnTarget (standingMark));
print ("Stepped on the first square");
standingMark.gameObject.SetActive (false); // Hide
// Wait until user has touched the zeppelin
yield return new WaitUntil (() => zeppelinHitChecker.currentHitState);
print ("Looked at the Zeppelin");
movingComponent.interpolator = 0; // This triggers the start of Zeppelin's animation
// Wait for moving animation to end
yield return new WaitUntil (() => movingComponent.isDone);
print ("Zeppelin's animation is done");
// set the next target square to be visible
originMark.gameObject.SetActive (true);
// Move to center of the room
yield return new WaitWhile (() => !IsStandingOnTarget (originMark));
print ("Stepped on the origin square");
originMark.gameObject.SetActive (false); // Hide
// Wait until user has touched the cube
yield return new WaitUntil (() => cubeHitChecker.currentHitState);
rotatingComponent.isRotating = true; // Start rotating cube
print ("Looked at the cube");
// Wait one second while it rotates
yield return new WaitForSecondsRealtime (1f);
rotatingComponent.isRotating = false; // Stop rotating cube
print ("One second has passed");
// one run is over
print ("Trial " + trialCounter + " is over");
// check if the trial counter is greater than or equal to the max trials
if (trialCounter >= maxTrials)
{
print ("All trials are over");
isRunning = false;
// quit the application
#if UNITY_EDITOR
// Application.Quit() does not work in the editor so
// UnityEditor.EditorApplication.isPlaying need to be set to false to end the game
EditorApplication.isPlaying = false;
#else
Application.Quit ();
#endif
}
// RESET everything
standingMark.gameObject.SetActive (true); // Show the target mark again
movingComponent.transform.position = movingComponent.startPoint;
rotatingComponent.transform.rotation = Quaternion.identity;
print ("Everything's reset!");
}
}
The changes are the trialCounter variable, which is incremented at the beginning of the function, and the check if the trial counter is greater than or equal to the max trials. If it is, the application will quit itself.
Make sure to change it accordingly, and test it out!