Skip to content

Recipe ‐ Agilitytest

oOoQo edited this page Feb 14, 2025 · 5 revisions

This project will show you how to build a Unity game which accepts body movement as input using OptiTrack.

About the game AgilityTest

The researchers of the enterprise Roboty have developed a new anthropomorphic robot which is you. Since your series shall be known for your agility and the world loves numbers, you need to be tested before the enterprise launches a marketing campaign. Your test is to collect coins and survive as long as possible while obstacles pass the room.

Requirements

  • Laptop or PC
  • USB flash drive or portable hard drive with at least 2GB empty space
  • Unity Version 2022.3.15f1
  • For Motion Capturing
    • Motive:Body 3.10
    • OptiTrack motion capture system
    • OptiTrack motion capture suit
  • Game Assets

Steps

The game build will be composed of three scenes.

  • Start
    • In this scene the player will enter the OptiTrack data, decide what they can and want to do in the test, enter the room size and set up the difficulty of the test.
  • MainScene
    • In this scene the test happens. By touching StartCube at the front of the room, the test starts and the player needs to avoid the obstacles while collecting the coins. If an obstacle is touched, the test ends.
  • End
    • In this scene, the survived time, the collected coins, the initial and current spawn time as well as the initial and current obstacle speed is shown.

This recipe will first show how to build MainScene, then Start and finally End.

Disclaimer

As the focus of this recipe is to have a running build at the end and not a totally finished game, this recipe will only explain how to create a build which can be played. Therefore, do not expect the things of a finished game in this recipe, like beauty, detailed verification of text input and consideration for the edgecases.

Setup

  1. Create a folder named AgilityTest on the portable drive. This will be the place where the Maya and the Unity project, as well as the build will be.
  2. Open Unity Hub and click on the blue button New Project to create a new project.
  3. Select Editor Version: 2022.3.15f1 and Universal 3D. Enter "AT" as Project Name and navigate to the folder AgilityTest for Locaction. Deselect Connect to Unity Cloud.
  4. Hit the blue button Create project.
  5. While the project opens, download the OptiTrack Unity Plugin.
  6. After the project opened, navigate to the folder Assets in the panel Project.
  7. Drag the downloaded plugin here. The window Import Unity Package will appear.
  8. Click on the button All and then on Import.
  9. Open Maya and click on the button New to create a new scene.
  10. To set up a project, go to File > Project Window and click on New. Enter "Maya" as the Current Project and select the folder AgilityTest in the field Location.
  11. Click Accept.

Scale and direction of the world in the game

Since this game uses body movement as input, we will maintain Unity's default unit (1 unit = 1m) to make scaling and moving assets easier. The center of the origin (0, 0, 0) will be the center of the room floor. In the global world the x dimension will be referred as the width, y the height and z the depth. The containers in which the obstacle and the coins are encapsulated will have their pivot point at the front right lower corner. These containers will move from the back of the room to the front of the room.

Naming convention from above Naming convention from the side Container with its pivot point

Building the assets in Maya

This game will be made with simple shapes, which is why Unity's 3D objects would suffice. However, since the pivot point of the objects cannot be changed Unity and the scale needs to be clean for manipulation, the shapes need to be rebuild in Maya.

Changing the grid size

In Maya 1 unit is 1cm. The grid shown in the default scene has the size 24cm x 24cm with one square being 1cm x 1cm. Since the game has assets which are bigger, the grid size needs to be changed.

Maya's default grid

  1. Go to Display and click on the square on the right of Grid to open the window Grid Options.
  2. Enter "100" for Length and Width and "1" for Subdivisions. Now the grid size is 2m x 2m while one square is 5cm x 5cm.
  3. For Orthographic grid numbers select "Along edge" to display the numbers in the orthographic view.
  4. Click on Accept and Close.

1m x 1m plane for building the room

The player can adjust the room size of the game to match the real space. To achieve that, the scale of the object and its position will be used. As Unity's default plane has the size 10m x 10m, a plane with the size 1m x 1m is needed.

  1. Create a Polygone plane and doubleclick it in the panel Outliner to change its name to "Tile".
  2. In the panel Channel Box/Layer Editor under Inputs click the word "polyPlane1". Adjust the values to the ones in the picture.

PolyPlane

  1. Since the view is filled with the object, select the object and press the key "F" to refocus the object.
  2. If you are not in the object mode for selection, go to the panel Modeling toolkit and click on Object mode.
  3. While having the mesh selected in object mode, perform Edit > Delete by type > History and Modify > Freeeze transformation.
  4. Change from the workspace General to UV Editing.
  5. In the panel UV Toolkit under Selection click on UV Shell Selection and select the tile.
  6. While it is selected, click on Unfold under Unfold in the same panel.
  7. In the panel UV Editor move, scale and rotate the tile, so that it is within "U1V1 1001".
  8. Change back to the workspace General and select the tile in object mode.
  9. Perform step 5 again.
  10. While the tile is selected, click on File > Export selection. The window Export Selection will appear.
  11. Create a folder Model and save the object as an FBX file under the name "OneMeterC" in this folder.

1m x 1m x 10cm cuboid for building the obstacle

Since it is difficult to jump over 1m without running, the cuboid will have a depth of 10cm to avoid the need of shrinking the depth everytime when using the object.

  1. Create a Polygone cube and doubleclick it in the panel Outliner to change its name to "ObstacleE".
  2. In the panel Channel Box/Layer Editor under Inputs click the word "polyCube1". Adjust the values to the ones in the picture.

Cuboid

  1. With the object selected, press "F" to refocus the object.
  2. In the same panel enter "-50" for Translate X and Translate Y and "-5" for Translate Z. Now the front lower left corner should be in the origin.
  3. Activate Snap to points and with the cuboid selected press the key "W" to show the gizmo and then "D" to be able change the pivot point placement.
  4. Move the gizmo to the front lower left corner and press "Q" to stop changing its placement.
  5. Now repeat the tile instruction's step 4 to 12.
  6. Save the cuboid under the name "ObstacleE" in the folder Model.

10cm x 10cm x 10cm prism for building the obstacle

To also have obstacles which have skewed edges, a prism with a triangle as a base will be built.

  1. Create a Polygone cube and doubleclick it in the panel Outliner to change its name to "Triangle".
  2. In the panel Channel Box/Layer Editor under Inputs click the word "polyCube1". Adjust the values to the ones in the picture.

Triangle

  1. With the object selected, press "F" to refocus the object.
  2. In the same panel enter "5" for Translate X and Translate Y and "-5" for Translate Z. Now the front lower left corner should be in the origin.
  3. Activate Snap to points and with the cube selected press the key "W" to show the gizmo and then "D" to be able change the pivot point placement.
  4. Move the gizmo to the front lower left corner and press "Q" to stop changing its placement.
  5. Repeat the tile instruction's step 4 and 5.
  6. In the edge mode delete the orange edge as shown in the picture.

EdgeMode

  1. In the vertice mode delete the two yellow points as shown in the picture.

VerticeMode

  1. Repeat the tile instruction's step 6 and 7.
  2. Delete the UV Shell in the panel UV Editor. The prism should still be visible in the perspective view after this action.
  3. In the panel UV Toolkit under Selection click on Face Selection and select all the faces of the prism in the perspective view.
  4. While all the faces are selected, click on UV > Automatic.
  5. While the faces are still selected, click on Layout under Align and Layout in the panel UV toolkit.
  6. Repeat the tile instruction's step 10 and 12.
  7. Save the cuboid under the name "Triangle" in the folder Model.

25cm x 25cm x 5cm cylinder for a coin

  1. Create a Polygone cylinder and doubleclick it in the panel Outliner to change its name to "Coin".
  2. In the panel Channel Box/Layer Editor under Inputs click the word "polyCylinder1". Adjust the values to the ones in the picture.

polyCylinder

  1. With the object selected, press "F" to refocus the object.
  2. In the same panel enter "90" for Rotate X.
  3. Repeat the tile instruction's step 4 to 12.
  4. Save the cylinder under the name "Coin" in the folder Model.

Save the scene and close Maya because we are finished with creating.

Importing the assets in Unity

  1. In the panel Project navigate to the folder Assets.
  2. Click the right mouse button (=RMB) and select Create > Folder to a new folder.
  3. Name it Models. All the models will be saved here.
  4. In this folder click RMB and select Import new Asset.
  5. Navigate to Maya/scenes/models where the exported models are saved, select all the models and click on Import.
  6. Select all the models in the panel Project and do the following in the panel Inspector:
    • In Rig set Animation Type to None and click Apply.
    • In Animation deselect Import Animation and click Apply.
  7. Download the Y-Bot from Mixamo and place it in the same folder.
    • Visit the website and log in
    • Go to Characters and type "Y Bot" in the searchbar to find the character.
    • Select the character and click on Download.
    • In the window Download Settings choose "FBX for Unity" as Format and "T-Pose" as Pose
  8. Select the character and do the following in the panel Inspector:
    • In Rig set Animation Type to Humanoid and click Apply.
    • In Animation deselect Import Animation and click Apply.
  9. Create a new folder Sounds under Assets in the panel Project. Everything related to sounds will be saved here.
  10. Download the two sounds (see Requirements) and save them in this folder.
  11. Click RMB and select Create > Audio Mixer.
  12. Name it "AudioMixer" and doubleclick it to open it.
  13. Under Groups select Master.
  14. Click RMB and select Add child group. Name it "Coins". All the coin sounds will end up here.
  15. Repeat the two steps to create another group called "Obstacles" for the obstacle sounds.

Materials

  1. Create a new folder Materials under Assets in the panel Project. All the materials will be saved here.
  2. In this folder create five materials and adjust them to your liking. To create one, click RMB and select Create > Material:
    • Coin_Mat for the coin
    • Obstacle_Mat for the obstacle
    • StartCube_Mat for the StartCube
    • Joint_Mat for the joins of the character
    • Surface_Mat for the surface of the character

Grid pattern in the room

To give the player some orientation how big and where the obstacles are, the room will have grid patterns. To ensure a uniform size of the squares, a custom shader will be built.

Shader Graph of the Grid_Shader

  1. Create a new folder Shaders under Assets in the panel Project. All the shaders will be saved here.
  2. In this folder click RMB and select Create > Shader Graph > URP > Lit Shader Graph.
  3. Name the shader "Grid_Shader" and doubleclick it to open the Shader Editor.
  4. Create the following inputs:
Name Type Default
Line Thickness Float 0.25
Line Color Color 171717
Background Color Color FAFAFA
Tile Size Float 0.5
  1. To get the grid pattern the node Rectangle will be used. Replicate the picture to have the ability to adjust the colors and the line thickness.

rectangle

  1. To have a grid the node Fraction will be plugged in the UV of the node Rectangle. How often a square of the size x fits in the plane will be determined by the plane's scale, because the scale also represents the plane size due to the fact that the plane is originally 1m x 1m. Replicate the picture to finish the shader.

fraction

  1. In the folder Materials create a new material called "Grid_Mat".
  2. While the material is selected, click on the dropdown beside Shader the panel Inspector and select Shader Graphs > Grid_Shader.

Creating the Scene MainScene

This is the scene where the playing happens.

  1. Navigate to the folder Scenes, press RMB and select Create > Scene.
  2. Name it "MainScene" and doubleclick it to switch to this scene.
  3. Delete the scene SampleScene.
  4. Open the window Build Settings by clicking on File > Build Settings....
  5. In Scenes In Build select Scenes/SampleScene, click RMB and click on Remove Selection.
  6. To add MainScene, click Add Open Scenes.
  7. Close this window.

Create the GameObject GameSettings

This object will know all the game settings. For the final game, it will be placed in the scene Start because the player will enter the settings there while MainScene is just intended for playing. Since we are building MainScene before Start, this GameObject is now placed here to test MainScene.

  1. In the panel Hierachy of MainScene click RMB and select Create Empty.
  2. Name the GameObject "GameSettings" and set its position to (0,0,0).
  3. Create a new folder Scripts under Assets in the panel Project. All the scripts will be saved here.
  4. In this folder click RMB and select Create > C# Script.
  5. Name the new script GameSettings and doubleclick it to open it.
  6. Enter the following code:
using System;
using System.Collections;
using UnityEngine;

public class GameSettings : MonoBehaviour
{
    public static GameSettings instance = null;
    public bool debugState = true;

    [Header("OptiTrack")]
    public string serverAdress = "127.0.0.1";
    public string localAdress = "127.0.0.1";
    public string skeletonAssetName = "Skeleton";


    [Header("Room")]
    public float floorWidth = 5.4f;
    public float floorDepth = 5f;
    public float wallHeight = 3f;
    public float tileSize = 0.5f;

    [Header("Player Abilities")]
    public bool usingWheelChair = false;
    public float seatHeight = 0.45f;
    public bool canJump = false;
    public float jumpHeight = 0.25f;
    public bool canDuck = false;
    public bool canLieOnFloor = false;

    [Header("Obstacles")]
    public bool objectPooling = false;
    [SerializeField] private float initialWorldSpeed = 1f;
    [SerializeField] private float initialSpawnTime = 3f;
    [SerializeField] private float decayRate = 0.97716f;
    [SerializeField] private float speedIncreaseRate = 1.01f;
    private float startTime = 0;
    private float endTime = 0;
    private bool testEnded = false;
    public float currentWorldSpeed = 1f;
    public float currentSpawnTime = 3f;

    public int coins = 0;

    public event Action OnTestStart;
    public event Action OnCoinChange;
    public event Action OnTestEnd;

    private void Awake()
    {
        CreateSingleton();
        currentWorldSpeed = initialWorldSpeed;
        currentSpawnTime = initialSpawnTime;
    }

    public void AddCoin(int amountToAdd)
    {
        coins += amountToAdd;
        OnCoinChange?.Invoke();
    }

    void CreateSingleton()
    {
        if (instance == null)
            instance = this;

        DontDestroyOnLoad(gameObject);
    }

    public void StartTest()
    {
        OnTestStart?.Invoke();
        StartCounter();
    }

    public void StartCounter()
    {
        startTime = Time.time;
        StartCoroutine(UpdateDifficulty());
    }

    private IEnumerator UpdateDifficulty()
    {
        float passedMinutes = (Time.time - startTime) / 60;
        currentSpawnTime = initialSpawnTime * Mathf.Pow(decayRate, passedMinutes);
        currentWorldSpeed = initialWorldSpeed * Mathf.Pow(speedIncreaseRate, passedMinutes);

        yield return new WaitForSeconds(60);

        if (!testEnded)
        {
            StartCoroutine(UpdateDifficulty());
        }
    }

    public void EndTest()
    {
        endTime = Time.time;
        testEnded = true;
        OnTestEnd?.Invoke();
    }

    public void SetServerAddress(string address)
    {
        this.serverAdress = address;
    }

    public void SetLocalAddress(string address)
    {
        this.localAdress = address;
    }

    public void SetSkeletonAssetName(string name)
    {
        this.skeletonAssetName = name;
    }

    public void SetUsingWheelChair(bool useWheelChair)
    {
        this.usingWheelChair = useWheelChair;
    }

    public void SetSeatHeight(string seatHeight)
    {
        this.seatHeight = float.Parse(seatHeight);
    }

    public void SetCanJump(bool canJump)
    {
        this.canJump = canJump;
    }

    public void SetJumpHeight(string jumpHeight)
    {
        this.jumpHeight = float.Parse(jumpHeight);
    }

    public void SetCanDuck(bool canDuck)
    {
        this.canDuck = canDuck;
    }

    public void SetCanLieOnFloor(bool canLieOnFloor)
    {
        this.canLieOnFloor = canLieOnFloor;
    }

    public void SetFloorWidth(string width)
    {
        this.floorWidth = float.Parse(width);
    }

    public void SetFloorDepth(string depth)
    {
        this.floorDepth = float.Parse(depth);
    }

    public void SetWallHeight(string wallHeight)
    {
        this.wallHeight = float.Parse(wallHeight);
    }

    public void SetInitialSpawnTime(string initialSpawnTime)
    {
        this.initialSpawnTime = float.Parse(initialSpawnTime);
    }

    public void SetInitialWorldSpeed(string initialWorldSpeed)
    {
        this.initialWorldSpeed = float.Parse(initialWorldSpeed);
    }

    public void SetDecayRate(string minutesString)
    {
        float minutes = float.Parse(minutesString);
        this.decayRate = Mathf.Pow(0.5f, 1.0f / minutes);
    }

    public void SetSpeedIncrease(string minutesString)
    {
        float minutes = float.Parse(minutesString);
        this.speedIncreaseRate = Mathf.Pow(2.0f, 1.0f / minutes);
    }

    public float GetPlayedTimeInSec()
    {
        return endTime - startTime;
    }

    public float GetInitialWorldSpeed()
    {
        return this.initialWorldSpeed;
    }

    public float GetInitialSpawnTime()
    {
        return this.initialSpawnTime;
    }
}
  1. Attach this script to the GameObject by selecting the GameObject, clicking on Add Component and searching for "GameSettings".

Creating the room

The size of the room is dependent on the entered values of the player. Optitrack's origin is also the room's origin.

  1. Create the script RoomSetup in the folder Scripts and enter the following code:
using UnityEngine;

public class RoomSetup : MonoBehaviour
{
    [Header("Floor")]
    public GameObject floor;
    float floorWidth = 2f;
    float floorDepth = 2f;

    [Header("Walls")]
    public GameObject wallL;
    public GameObject wallR;
    public GameObject wallB;
    float wallHeight = 3f;

    [Header("Ceiling")]
    public GameObject ceiling;

    // Start is called before the first frame update
    void Start()
    {
        floorWidth = GameSettings.instance.floorWidth;
        floorDepth = GameSettings.instance.floorDepth;
        wallHeight = GameSettings.instance.wallHeight;
        ChangeRoomSize(floorWidth, floorDepth, wallHeight);
    }

    public void ChangeRoomSize(float floorWidth, float floorDepth, float wallHeight)
    {
        this.floorDepth = floorDepth;
        this.floorWidth = floorWidth;
        this.wallHeight = wallHeight;

        floor.transform.localScale = new Vector3(floorWidth, 0, floorDepth);

        ceiling.transform.localScale = floor.transform.localScale;
        ceiling.transform.position = new Vector3(0, wallHeight, 0);

        wallL.transform.localScale = new Vector3(wallHeight, 0, floorDepth);
        wallR.transform.localScale = new Vector3(wallHeight, 0, floorDepth);
        wallL.transform.position = new Vector3(floorWidth / -2, wallHeight / 2, 0);
        wallR.transform.position = new Vector3(floorWidth / 2, wallHeight / 2, 0);
        wallB.transform.localScale = new Vector3(floorWidth, 0, wallHeight);
        wallB.transform.position = new Vector3(0, wallHeight / 2, floorDepth / 2);
    }
}
  1. Create a folder Prefabs in Assets. This folder will contain all the prefabs.
  2. In this folder create the folder BaseModels which contains prefabs that serve as building blocks.
  3. Drag OneMeterC from Models into the panel Hierachy to place it into the scene.
  4. Set Position to (0,0,0).
  5. While it is selected in the hierachy, click RMB and select Prefab > Unpack.
  6. Drag and drop the material Grid_Mat on this object.
  7. Drag and drop the GameObject from Hierachy to Prefabs/BaseModels to turn it into a prefab.
  8. Delete the object from Hierachy.
  9. Create an empty object called "Room" and set Position to (0,0,0).
  10. Drag and drop the prefab OneMeterC from Prefabs/BaseModel to Hierachy as the GameObject Room's child and duplicate four times so that Room has five children in total.
  11. Adjust the children the following way:
Name Position Rotation Scale
Floor 0,0,0 0,0,0 3,1,3
Ceiling 0,3,0 0,180,0 3,1,3
WallL -1.5,1.5,0 0,0,-90 3,1,3
WallR 1.5,1.5,0 0,180,-90 3,1,3
WallB 0,1.5,1.5 -90,0,0 3,1,3
  1. Select the GameObject Room and add the component Room Setup
  2. Fill this component with the children.
  3. If you run the game in the editor, the room should become bigger because the default setting of the room in GameSettings is (5.4, 5, 3)

Running the game after adding the component Room Setup

Adding the character to the scene

  1. Drag Y-Bot from Models into the panel Hierachy to place it into the scene.
  2. While it is selected in the hierachy, click RMB and select Prefab > Unpack.
  3. In the panel Inspector do the following:
    • Change the name to "Character"
    • Set Position to (0,0,0).
    • For the interaction with the coins and the obstacles:
      • Set Tag to "Player".
      • Add the component Rigidbody
        • Set Mass to "50".
        • As the player is controlling the character:
          • Deselect Use Gravity
          • Select Is Kinematic
  4. In Hierachy open the GameObject to see its children.
  5. Drag the material Joint_Mat to the GameObject Alpha_Joints, while dragging Surface_Mat to Alpha_Surface.

Adding Colliders

Beside the component Rigidbody, Collider is needed for the interaction. As OptiTrack is moving the rig (= the GameObjects named mixamorig:...), this component needs to be added to each bone. Bones are by default not visible in Unity.

  1. Open Window > Package Manager and select Unity Registry in the dropdown Packages.
  2. Search for the package Animation Rigging and install it by clicking on Install.
  3. Select the character in Hierachy and click on Animation Rigging > Bone Renderer Setup. Now the bones of the character should be visible.
  4. Add to every GameObject named mixamorig:... either the component Box Collider, Sphere Collider or Capsule Collider.
    • Choose and edit the collider so that it fits the mesh closely because only when the player really touches something, do we want to have an interaction.
    • Since the fingers will not be motion captured singularly, colliders do not need to be added to them. Letting the collider of the hand envelop them is enough.
    • Since the transform of the rig should not be changed, some bones (e.g. mixamorig:LeftFoot) will need an empty child which holds the collider because this GameObject can be transformed.

The left version just shows the bones while the right version shows the bones and the added colliders

  1. After all the bones have a collider, the component Bone Renderer on the GameObject Character can be removed.

Add OptiTrack logic

For the game, the player will enter the server and the local address as well as the name of the used skeleton. This data will be read from GameSettings by some script and then be given to the official OptiTrack scripts.

  1. Create the script StartOptiTrackClient in the folder Scripts and enter the following code:
using UnityEngine;

public class StartOptiTrackClient : MonoBehaviour
{
    OptitrackStreamingClient client = null;

    // Start is called before the first frame update
    void Start()
    {
        client = gameObject.GetComponent<OptitrackStreamingClient>();

        if (client != null)
        {
            client.ServerAddress = GameSettings.instance.serverAdress;
            client.LocalAddress = GameSettings.instance.localAdress;
            client.enabled = true;
        }
    }
  1. Create the script StartOptiTrackSkeletonAnimator in the folder Scripts and enter the following code:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class StartOptiTrackSkeletonAnimator : MonoBehaviour
{
    OptitrackSkeletonAnimator optitrackSkeletonAnimator = null;

    // Start is called before the first frame update
    void Start()
    {
        optitrackSkeletonAnimator = this.gameObject.GetComponent<OptitrackSkeletonAnimator>();
        if (optitrackSkeletonAnimator != null)
        {
            optitrackSkeletonAnimator.SkeletonAssetName = GameSettings.instance.skeletonAssetName;
            optitrackSkeletonAnimator.enabled = true;
        }
    }
}
  1. In the panel Project navigate to Assets/optitrack-unity/OptiTrack/Prefabs.
  2. Drag and drop the prefab Client - OptiTrack in the panel Hierachy.
  3. Adjust the settings of the component Optitrack Streaming Client according to the picture. As shown in the picture the component should also be set to inactive because Start Opti Track Client will activate it.

The adjusted component Optitrack Streaming Client

  1. Add the component Start Opti Track Client to the prefab.
  2. Select the character and add the components Start Opti Track Skeleton Animator and Optitrack Skeleton Animator.
  3. For Optitrack Skeleton Animator:
    • Set it to inactive because Start Opti Track Skeleton Animator will activate it.
    • Set Streaming Client to the prefab Client - OptiTrack.
    • Set Destination Avatar to Y BotAvatar.

Creating the GameObject StartCube

After the player has entered the game settings, they will need some time to reach the front of the room. To avoid having a timer, StartCube serves as the button to start the test. It will be placed at the front of the room.

  1. Create the script StartLevel in the folder Scripts and enter the following code:
using UnityEngine;

public class StartLevel : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        SetPosition();
    }

    private void SetPosition()
    {
        float zPos = GameSettings.instance.floorDepth / -2 + 0.25f;
        transform.position = new Vector3(0, 0.5f, zPos);
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.attachedRigidbody != null & other.attachedRigidbody.CompareTag("Player"))
        {
            GameSettings.instance.StartTest();
            gameObject.SetActive(false);
        }
    }
}
  1. In Hierachy click RMB and select 3D Object > Cube.
  2. Name it "StartCube"
  3. Set Position to (0, 0.5, 0) and Scale to (0.5,0.5,0.5).
  4. In the component Box Collider activate Is Trigger.
  5. Add the component Start Level

If one runs the game in the editor, StartCube should move to the front of the room like in the following picture:

After adding StartCube

As all the provided scripts are their final version, the next things (camera, obstacles, UI of MainScene, ...) can only be tested after the creation of the scene Start is finished. Running the game in the editor before Start is finished will result in NullException since the next scripts rely on GameSettings exisiting before them. Errors related to OptiTrack will also appear in the console but they are not negible when testing just these scripts.

Adjusting main camera

At the beginning the camera will be static and show StartCube so that the player can move to it. After the test started, it will follow the player.

  1. Create the script CameraFollower in the folder Scripts and enter the following code:
using UnityEngine;

public class CameraFollower : MonoBehaviour
{
    private float roomOffSet = 1.2f;
    public GameObject followedObject;
    public Vector3 followingOffSet = new Vector3(0, 0, -2);
    private bool isFollowing = false;


    // Start is called before the first frame update
    void Start()
    {
        SetPosition();
    }

    void OnEnable()
    {
        GameSettings.instance.OnTestStart += StartFollowing;
    }

    // Update is called once per frame
    void Update()
    {
        if (isFollowing)
        {
            UpdatePosition();
        }
    }

    void OnDisable()
    {
        GameSettings.instance.OnTestStart -= StartFollowing;
    }

    private void SetPosition()
    {
        float zPos = GameSettings.instance.floorDepth / -2 - roomOffSet;
        transform.position = new Vector3(0, followedObject.transform.position.y, zPos);
    }

    void UpdatePosition()
    {
        transform.position = followedObject.transform.position + followingOffSet;
    }

    private void StartFollowing()
    {
        isFollowing = true;
    }
}
  1. Select Main Camera in Hierachy and set Position to (0,1.7,-2).
  2. In the component Camera set Field of View to "90".
  3. Add the component Camera Follower
  4. Set Followed Object to mixamorig:Head and Following Off Set to (0,0,-2)

Creating the component Container

All the obstacles and coins are placed in a container or its subclass. The container is responsible for moving from the back of the room to the front and getting destroyed when leaving the room. To achieve that it contains information about its size. It also has information about whether its content is appropriate for the test or not. The pivot point of all containers should be the front lower right corner.

The GameObject Container has the two optional children, Mesh and Coin:

  • If Mesh exists, it contains all the obstacle meshes.
  • If Coins exists, it contains all the coins and they will be randomly activated when the container is spawned.
  1. Create the script Container in the folder Scripts and enter the following code:
using UnityEngine;

public class Container : MonoBehaviour
{
    public GameObject meshGroup = null;
    public GameObject coinGroup = null;

    [Header("Needed Abilities")]
    public bool noWheelChair = true;
    public bool needJump = false;
    public bool needDuck = false;
    public bool needLying = false;

    [Header("Info about the container")]
    public float width;
    public float depth;
    [SerializeField] private float speed;
    private Vector3 startPos;
    private Vector3 direction = new Vector3(0, 0, -1);
    // z value when the container leaves the room
    private float endZPos = 0;

    public enum ContainerStates
    {
        Initialized,
        Moving,
        TouchedPlayer,
        DestroyObject
    }

    public ContainerStates state = ContainerStates.Initialized;

    // Update is called once per frame
    void Update()
    {
        if (state == ContainerStates.Moving)
        {
            transform.Translate(speed * Time.deltaTime * direction);

            if (transform.position.z < endZPos)
            {
                state = ContainerStates.DestroyObject;
                Reset();
            }
        }
    }

    public bool MeetsTheRequirements()
    {
        bool doesMeet = true;

        if (GameSettings.instance.usingWheelChair == true && noWheelChair)
        {
            doesMeet = false;
        }

        if (GameSettings.instance.canJump == false && needJump)
        {
            doesMeet = false;
        }

        if (GameSettings.instance.canDuck == false && needDuck)
        {
            doesMeet = false;
        }

        if (GameSettings.instance.canLieOnFloor == false && needLying)
        {
            doesMeet = false;
        }

        return doesMeet;
    }

    public virtual void Move(Vector3 startPos, float speed)
    {
        endZPos = GameSettings.instance.floorDepth / (-2) - depth;
        this.startPos = startPos;
        this.speed = speed;
        if (coinGroup != null)
        {
            SpawnRandomObjectsFromGroup(coinGroup);
        }

        Initialize();

        state = ContainerStates.Moving;
    }

    public virtual void Initialize()
    {

    }

    public void SpawnRandomObjectsFromGroup(GameObject objectGroup)
    {
        for (int i = 0; i < objectGroup.transform.childCount; i++)
        {
            if (Random.Range(0, 2) == 0)
            {
                objectGroup.transform.GetChild(i).gameObject.SetActive(false);
            }
            else
            {
                objectGroup.transform.GetChild(i).gameObject.SetActive(true);
            }
        }
    }

    public virtual void Reset()
    {
        Destroy(this.gameObject);
    }
}

Creating a coin

  1. Create the script Coin in the folder Scripts and enter the following code:
using UnityEngine;

public class Coin : MonoBehaviour
{
    private void OnEnable()
    {
        gameObject.GetComponent<MeshRenderer>().enabled = true;
        gameObject.GetComponent<BoxCollider>().enabled = true;
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.attachedRigidbody != null & other.attachedRigidbody.CompareTag("Player"))
        {
            GameSettings.instance.AddCoin(1);
            gameObject.GetComponent<AudioSource>().PlayOneShot(gameObject.GetComponent<AudioSource>().clip);
            gameObject.GetComponent<MeshRenderer>().enabled = false;
            gameObject.GetComponent<BoxCollider>().enabled = false;
        }
    }
}
  1. Drag Coin from Models into the panel Hierachy to place it into the scene.
  2. While it is selected in the hierachy, click RMB and select Prefab > Unpack.
  3. In the panel Inspector do the following:
    • Set Position to (0,0,0).
    • For the interaction with the player:
      • Select Add Tag at the dropdown for Tag and create a new tag named "Coin".
      • Set Coin as the GameObject's Tag
      • Add the component Box Collider
        • Activate Is Trigger
      • Add the component Audio Source
        • Select for AudioClip the sound mixkit-arcade-jump-coin-216
        • Select for Output the Audio Mixer Group Coins
        • Deselect Play on Awake
      • Add the component Coin
  4. Drag the material Coin_Mat to the GameObject.
  5. Drag and drop the GameObject from Hierachy to the folder Prefabs to turn it into a prefab.
  6. Delete the object in Hierachy.

Creating the building blocks for the obstacles

  1. Create the script ObstacleMesh in the folder Scripts and enter the following code:
using UnityEngine;

public class ObstacleMesh : MonoBehaviour
{
    [SerializeField] private GameObject obstacle = null;
    private Container container = null;

    // Start is called before the first frame update
    void Start()
    {
        obstacle = transform.parent.parent.gameObject;
        container = obstacle.GetComponent<Container>();
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.attachedRigidbody != null & other.attachedRigidbody.CompareTag("Player"))
        {
            if (container.state != Container.ContainerStates.TouchedPlayer)
            {
                gameObject.GetComponent<AudioSource>().Play();

                if (GameSettings.instance.debugState == false)
                {
                    container.state = Container.ContainerStates.TouchedPlayer;
                    GameSettings.instance.EndTest();
                }
            }
        }
    }
}
  1. Drag ObstacleE from Models into the panel Hierachy to place it into the scene.
  2. While it is selected in the hierachy, click RMB and select Prefab > Unpack.
  3. In the panel Inspector do the following:
    • Set Position to (0,0,0).
    • For the interaction with the player:
      • Add the component Box Collider
        • Activate Is Trigger
      • Add the component Audio Source
        • Select for AudioClip the sound mixkit-apartement-buzzer-bell-press-932
        • Select for Output the Audio Mixer Group Obstacles
        • Deselect Play on Awake
      • Add the component ObstacleMesh
  4. Drag the material Obstacle_Mat to the GameObject.
  5. Drag and drop the GameObject from Hierachy to the folder Prefabs/BaseModels to turn it into a prefab.
  6. Delete the object in Hierachy.
  7. Drag Triangle from Models into the panel Hierachy to place it into the scene.
  8. While it is selected in the hierachy, click RMB and select Prefab > Unpack.
  9. In the panel Inspector do the following:
    • Set Position to (0,0,0).
    • For the interaction with the player:
      • Add the component Mesh Collider
        • Activate Convex and Is Trigger
      • Add the component Audio Source
        • Select for AudioClip the sound mixkit-apartement-buzzer-bell-press-932
        • Select for Output the Audio Mixer Group Obstacles
        • Deselect Play on Awake
      • Add the component ObstacleMesh
  10. Drag the material Obstacle_Mat to the GameObject.
  11. Drag and drop the GameObject from Hierachy to the folder Prefabs/BaseModels to turn it into a prefab.
  12. Delete the object in Hierachy.

Creating obstacles

As mentioned, obstacles either have a component Container or a subclass of it. The following steps show how to build an obstacle using this component.

TObstacle has 2m as its width and 0.1m as its depth. To touch all the coins the player needs to do a T-Pose.

TObstacle from the front view TObstacle from the right view TObstacle with the player

  1. In Hierachy create an empty object, name it "TObstacle" and set Position to (0,0,0).
  2. Create two empty object as children of TObstacle and name one "Mesh* while the other one is called "Coins".
  3. Add to TObstacle the component Container.
    • Drag the two children to the respective groups.
    • Only No Wheel Chair should be selected under Needed Abilities.
    • Set Width to "2" and Depth to "0.1"
  4. Give the GameObject Mesh two prefab ObstacleE as children and set their transform to the following values:
Position Rotation Scale
0, 0, 0.1 0, 0, 0, 0.5, 1.25 , 1
-1.5, 0, 0.1 0, 0, 0, 0.5, 1.25 , 1
  1. Give the GameObject Coins six prefab Coin as children and set their transform to the following values:
Position Rotation Scale
-1, 0.25, 0.05 0, 0, 0, 1, 1 , 1
-1, 0.75, 0.05 0, 0, 0, 1, 1 , 1
-1, 1.25, 0.05 0, 0, 0, 1, 1 , 1
-1, 1.75, 0.05 0, 0, 0, 1, 1 , 1
-0.25, 1.477, 0.05 0, 0, 0, 1, 1 , 1
-1.75, 1.477, 0.05 0, 0, 0, 1, 1 , 1
  1. Drag and drop the GameObject TObstacle from Hierachy to the folder Prefabs to turn it into a prefab.
  2. Delete TObstacle from Hierachy.

Following this process, four other obstacles were created: IObstacle, AObstacle, DObstacle and LObstacle

The character shown with IObstacle, AObstacle, DObstacle and LObstacle: The first row is the front view while the second row is the side view

Creating subclasses to add variety

The components Jump Obstacle and Wheel Chair Obstacle are examples of subclasses of the component Container. The picture shows one obstacle built with Jump Obstacle and the other two are made with Wheel Chair Obstacle.

The character shown with JObstacle, WObstacle and WCoins: The first row is the front view while the second row is the side view

Jump Obstacle

This component works the following way: It looks at the maximum jump height entered by the player and generates a random height between the minimum height (default: 5cm) and the maximum height. If the child Mesh exists, its children will be scaled according to this height. If the child Coins exists, all the coins in it will be moved up according to this height.

Its code look like this:

using UnityEngine;

public class JumpObstacle : Container
{
    float minJump = 0.05f;
    float randomHeight = 0;

    public override void Initialize()
    {
        randomHeight = Random.Range(minJump, GameSettings.instance.jumpHeight);

        if (meshGroup != null)
        {
            for (int i = 0; i < meshGroup.transform.childCount; i++)
            {
                Vector3 oldScale = meshGroup.transform.GetChild(i).gameObject.transform.localScale;
                meshGroup.transform.GetChild(i).gameObject.transform.localScale = new Vector3(oldScale.x, randomHeight, oldScale.z);
            }
        }

        if (coinGroup != null)
        {
            for (int i = 0; i < coinGroup.transform.childCount; i++)
            {
                coinGroup.transform.GetChild(i).gameObject.transform.position += new Vector3(0, randomHeight, 0);
            }
        }

    }
}

Wheel Chair Obstacle

This component works the following way: The obstacle should be built for the seat height 0.4m. The component looks at the seat height given by the player and adjusts the obstacle according to it. If the child Mesh exists, its children will be scaled according to (given seat height + 0.35m) for a T-Pose in the wheel chair. If the child Coins exists, all the coins in it will be moved according to the height difference between the given and the default seat height.

Its code look like this:

using UnityEngine;

public class WheelChairObstacle : Container
{
    // Height of the mesh
    float addedHeight = 0.35f;
    float finalHeight = 0.0f;

    // Placement of the coins
    float defaultSeatHeight = 0.4f;
    float difference = 0.0f;

    public override void Initialize()
    {
        finalHeight = GameSettings.instance.seatHeight + addedHeight;
        difference = GameSettings.instance.seatHeight - defaultSeatHeight;

        if (meshGroup != null)
        {
            for (int i = 0; i < meshGroup.transform.childCount; i++)
            {
                Vector3 oldScale = meshGroup.transform.GetChild(i).gameObject.transform.localScale;
                meshGroup.transform.GetChild(i).gameObject.transform.localScale = new Vector3(oldScale.x, finalHeight, oldScale.z);
            }
        }

        if (coinGroup != null)
        {
            for (int i = 0; i < coinGroup.transform.childCount; i++)
            {
                coinGroup.transform.GetChild(i).gameObject.transform.position += new Vector3(0, difference, 0);
            }
        }

    }
}

Creating the GameObject Spawner

This GameObject is responsible for spawning the appropriate containers after StartCube has been touched. The spawn time and the speed for the obstacle are obtained from GameSettings.

  1. Create the script Spawner in the folder Scripts and enter the following code:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Spawner : MonoBehaviour
{
    public GameObject[] obstacleList;
    [SerializeField] private List<GameObject> activeObstacles = new List<GameObject>();
    private Vector3 obstacleStartPos;
    private GameObject obstacleFolder;
    private float lastXPos = 0;
    private float lastWidth = 0;
    private bool play = false;

    // Start is called before the first frame update
    void Start()
    {
        SetPosition();
        FilterObstacles();
    }

    void OnEnable()
    {
        GameSettings.instance.OnTestStart += StartSpawning;
    }

    void OnDisable()
    {
        GameSettings.instance.OnTestStart -= StartSpawning;
    }

    private void SetPosition()
    {
        float zPos = GameSettings.instance.floorDepth / 2;
        transform.position = new Vector3(0, 0, zPos);
        obstacleStartPos = transform.position + Vector3.forward;
    }

    private void FilterObstacles()
    {
        obstacleFolder = new GameObject("Obstacles");
        obstacleFolder.transform.position = Vector3.zero;

        for (int i = 0; i < obstacleList.Length; i++)
        {
            if (obstacleList[i].GetComponent<Container>().MeetsTheRequirements())
            {
                activeObstacles.Add(obstacleList[i]);
            }
        }
    }

    private int SelectObstacleIndex()
    {
        return UnityEngine.Random.Range(0, activeObstacles.Count);
    }

    private float CalculateObstacleXPos(int index)
    {
        float xPos = 0;

        float obstacleWidth = activeObstacles[index].GetComponent<Container>().width;
        float min = obstacleWidth + GameSettings.instance.floorWidth / -2;
        float max = GameSettings.instance.floorWidth / 2;
        xPos = UnityEngine.Random.Range(min, max);

        lastWidth = obstacleWidth;
        lastXPos = xPos;

        return xPos;
    }

    private IEnumerator SpawnObstacle()
    {
        if (activeObstacles.Count != 0)
        {
            int selectedIndex = SelectObstacleIndex();
            float selectedXPos = CalculateObstacleXPos(selectedIndex);
            Vector3 startPos = new Vector3(selectedXPos, 0, obstacleStartPos.z);

            GameObject obstacle = Instantiate(activeObstacles[selectedIndex], startPos, Quaternion.identity);
            obstacle.transform.parent = obstacleFolder.transform;
            obstacle.GetComponent<Container>().Move(startPos, GameSettings.instance.currentWorldSpeed);

            yield return new WaitForSeconds(GameSettings.instance.currentSpawnTime);
            StartCoroutine(SpawnObstacle());
        }
    }

    private void StartSpawning()
    {
        StartCoroutine(SpawnObstacle());
    }
}
  1. In Hierachy create an empty object, name it "Spawner" and set Position to (0,0,0).
  2. Add the component Spawner.
    • Add all the Obstacle prefabs to Obstalce List.
      • In my case, they are: TObstacle, IObstacle, AOBstacle, DObstacle, LOBstacle, JumpObstacle, WObstacle and WCoins.

Creating the Canvas

MainScene starts with displaying the message "Please go and touch the cube in the room to start the test". After the player has touched StartCube, the message disappears and the collected coins are shown the left upper corner.

Message for touching StartCube Collected Coins UI

  1. Create the script MainSceneUI in the folder Scripts and enter the following code:
using TMPro;
using UnityEngine;

public class MainSceneUI : MonoBehaviour
{
    [SerializeField] private GameObject instructionPanel;
    [SerializeField] private GameObject coinPanel;
    [SerializeField] private TextMeshProUGUI coinText;

    void OnEnable()
    {
        GameSettings.instance.OnCoinChange += UpdateCoin;
        GameSettings.instance.OnTestStart += SwitchPanel;
    }

    void OnDisable()
    {
        GameSettings.instance.OnCoinChange -= UpdateCoin;
        GameSettings.instance.OnTestStart -= SwitchPanel;
    }

    void SwitchPanel()
    {
        instructionPanel.SetActive(false);
        coinPanel.SetActive(true);
    }

    void UpdateCoin()
    {
        coinText.text = GameSettings.instance.coins.ToString();
    }
}
  1. In Hierachy click RMB and select UI > Canvas.
  2. While the GameObject Canvas is selected, change the following values of the component Canvas Scaler in Inspector:
    • Set UI Scale Mode to Scale with screen size
    • Set Reference Resolution to (1920, 1080)
  3. Click RMB on Canvas in the Hierachy and select UI > Panel
  4. Remove the component Image from the GameObject Panel.
  5. Create another panel as a child Panel and name it "TouchCube".
  6. Do the following in Inspector:
    • In Rect Transform
      • Set Left, Top and Right to "100".
      • Set Bottom to "500"
    • In Image
      • Set the alpha value of Color to "194".
  7. Click RMB on TouchCube in the Hierachy and select UI > Text - TextMeshPro
  8. Since we are using TextMeshPro for the first time, the window TMP Importer will appear. Click on Import TMP Essentials and close the window.
  9. While the GameObject Text (TMP) is selected, change the following values in Inspector:
    • In Rect Transform
      • Set Width to "800" and Height to "200"
    • In TextMeshPro - Text (UI)
      • Text Input: "Please go and touch the cube in the room to start the test"
      • Font Style: Bold
      • Font Size: 70
      • Vertex Color: black
      • Alignment: Center & Middle
  10. Click RMB on the GameObject Panel and create another panel as a child.
  11. Name it "CoinInfo"
  12. Do the following in Inspector:
    • For Image set Color to white
    • Adjust the values of Rect Transform according to the picture:

Rect Transform of CoinInfo

  1. Add this GameObject two Text - TextMesh Pro as child objects: One named "Title" while the other is named "Value".
  2. Set the values of Title according to this picture:

Rect Transform and TextMeshPro - Text of CoinTitle

  1. Set the values of Value to this picture.

Rect Transform and TextMeshPro - Text of CoinValue

  1. Select the GameObject Canvas and add the component Main Scene UI:
    • Choose TouchCube for Instruction Panel
    • Choose CoinInfo for Coin Panel.
    • Choose Value for Coin Text
  2. Select the GameObject CoinInfo and set it to inactive.

Creating the scene Start

This is the scene where setting the game settings happens.

  1. Delete the GameObject GameSetting from the scene MainScene and save the scene.
  2. Navigate to the folder Scenes, press RMB and select Create > Scene.
  3. Name it "Start" and doubleclick it to switch to this scene.
  4. Recreate the GameObject GameSettings. See this section for the steps.
  5. Open the window Build Settings by clicking on File > Build Settings....
  6. In Scenes In Build click Add Open Scenes to add Start.
  7. Select this scene and drag it to the top, so that it is the first scene.
  8. Close this window.

The Canvas

The player needs to navigate through four panels:

  • OptiTrack Setup
    • Here the player enters:
      • Server address
      • Local address
      • The name of the skeleton assets

OptiTrackSetup

  • Player Setup
    • Here the player enters:
      • If they are using a wheelchair
      • Their seat height in meter
      • If they can or want to jump
      • Their max jump height in meter
      • If they can or want to squat
      • If they can or want to lie on the floor

PlayerSetup1 PlayerSetup2

  • Room Setup
    • Here the player enters in meter:
      • room width
      • room depth
      • room height

RoomSetupUI

  • Obstacle Setup
    • Here the player enters:
      • The initial spawn time in seconds
      • The inital obstacle speed in meter per seconds
      • When the spawn time is reduced to its half in minutes
      • When the obstacle speed is twice as fast in minutes

ObstacleSetup1 ObstacleSetup2

The following picture shows how the GameObject Canvas looks like in Hierachy.

Canvas' Hierachy in scene Start

Since there is a lot of repetition, this recipe will only explain:

  • how to switch between panels
  • how to create a scroll view
  • how to connect an input field to GameSettings
  • how to connect a toggle to GameSettings
  • how to load the next scene

How to switch between the panels

  1. Create the script PanelManager in the folder Scripts and enter the following code:
using UnityEngine;

public class PanelManager : MonoBehaviour
{
    [SerializeField] private GameObject[] panels;
    [SerializeField] private int indexOfCurrentPanel = 0;

    public void SwitchToNextPanel()
    {
        panels[indexOfCurrentPanel].SetActive(false);
        indexOfCurrentPanel++;
        panels[indexOfCurrentPanel].SetActive(true);
    }
}
  1. Add this component to the GameObject Canvas.
  2. Drag and drop the four panels in Panels and put them into the following order because the component will switch according to this sequence:
    1. OptiSetUp
    2. PlayerSetUp
    3. RoomSetup
    4. ObstacleSetUp
  3. To switch to the next panel, the user needs to press a button. The button can be created by clicking RMB and selecting UI > Button - TextMeshPro.
  4. In the component Button at On Click () click on the plus sign to add an EventListener.
  5. Drag and drop the GameObject Canvas to the field under the dropdown Runtime Only.
  6. For the dropdown No Function, click on it and select PanelManager > SwitchToNextPanel () The section should look like the following picture after the selection.

OnClickPanelSwitch

  1. The text of the button can be set in its child Text (TMP)

How to create a scroll view

  1. A scroll view can be created by clicking RMB and selecting UI > Scroll View.
  2. The size of this GameObject decides the size of the visible part.
  3. In the component Scroll Rect how the scrolling works can be set. Since this game only needs vertical scrolling, only Vertical needs to be activated.
  4. The content which will be scrolled needs to be a child of the grandchild Content.
  5. The size of the GameObject Content should be bigger than the actual content, so that they can be seen.

How to connect an input field to GameSettings

  1. An input field can be created by clicking RMB and selecting UI > Input Field - TextMeshPro.
  2. To avoid the user entering rich text, deselect Rich Text under Control Settings of the component TextMeshPro - Input Field.
  3. To give the input value to GameSettings add an EventListener to On End Edit (String). The setter functions have already been created and just need to be selected. When selecting the function, it is important to select the version under Dynamic string and not Static Parameters. The two pictures show an example for the server address.

The version under dynamic string should be selected After the dynamic version of SetServerAddress has been selected

  1. Since the game also have input fields which only needs decimal numbers (e.g. room size), the accepted value can be set under Content Type in the component TextMeshPro - Input Field.
  2. The placeholder text can be changed in the grandchild Placeholder.

How to connect a toggle to GameSettings

  1. A toggle can be created by clicking RMB and selecting UI > Toggle.
  2. Is On of the component Toggle determines the default state of the toggle.
  3. To give the input value to GameSettings add an EventListener to On Value Changed (Bolean). The setter functions have already been created and just need to be selected. Like with the input fields, it is important to select the function version under Dynamic bool and not Static Parameters.
  4. The label text can be changed in the child Label.

How to load the next scene

  1. Create the script SceneLoader in the folder Scripts and enter the following code:
using UnityEngine;
using UnityEngine.SceneManagement;

public class SceneLoader : MonoBehaviour
{
    public string sceneToLoad;
    AsyncOperation loadingOperation;
    private Scene currentScene;

    void OnEnable()
    {
        currentScene = SceneManager.GetActiveScene();
        
        if (currentScene.buildIndex != 0)
        {
            GameSettings.instance.OnTestEnd += LoadScene;
        }
    }

    private void OnDisable()
    {
        if (currentScene.buildIndex != 0)
        {
            GameSettings.instance.OnTestEnd -= LoadScene;
        }
    }

    public void LoadScene()
    {
        loadingOperation = SceneManager.LoadSceneAsync(sceneToLoad);
    }
}
  1. In Hierachy create an empty object, name it "SceneLoader" and set Position to (0,0,0).
  2. Add the component SceneLoader and enter "MainScene" for Scene to Load
  3. To switch to MainScene, the user needs to press a button. The button can be created by clicking RMB and selecting UI > Button - TextMeshPro.
  4. In the component Button at On Click () click on the plus sign to add an EventListener.
  5. Drag and drop the GameObject SceneLoader to the field under the dropdown Runtime Only.
  6. For the dropdown No Function, click on it and select SceneLoader > LoadScene ()
  7. The text of the button can be set in its child Text (TMP)

When the creation of this scene is finished, Start and MainScene should now work in the editor. OptiTrack is not needed for testing the logic because the character can be moved via the Move Tool. In the Console only errors related to OptiTrack should appear during the test since the game is not yet connected with it.

Creating the scene End

This scene displays the end values and has a button to quit the game.

EndScreen

  1. Navigate to the folder Scenes, press RMB and select Create > Scene.
  2. Name it "End" and doubleclick it to switch to this scene.
  3. Open the window Build Settings by clicking on File > Build Settings....
  4. In Scenes In Build click Add Open Scenes to add End.
  5. Select this scene and drag it to the bottom, so that it is the last scene.
  6. Close this window.

The Canvas

The following pictures shows the structure of the canvas.

Canvas' Hierachy in scene End

Creating the prefab Field

As the six fields are just a repetition of the prefab Field with changed text values
and position. Only how to create this prefab will be shown.

  1. Create a panel and name it "Field".
  2. Set the values according to the picture.

Rect Transform of Field

  1. Create a Text - TextMesh Pro as a child of Field and name it "Title".
  2. Set *Rect Transform according to this picture.

Rect Transform of Field's child Title

  1. In TextMeshPro - Text (UI)
    • Text Input: "Title"
    • Font Style: Bold
    • Font Size: 50
    • Vertex Color: black
    • Alignment: Left & Top
  2. Create another Text - TextMesh Pro as a child of Field and name it "Value".
  3. Set *Rect Transform according to this picture.

Rect Transform of Field's child Value

  1. In TextMeshPro - Text (UI)
    • Text Input: "Value"
    • Font Style: Regular
    • Font Size: 50
    • Vertex Color: black
    • Alignment: Right & Top
  2. Drag and drop the GameObject Field from Hierachy to the folder Prefabs to turn it into a prefab.
  3. Remove the GameObject from Hierachy.

Adding logic

  1. After the six fields have been created, create a button for quitting the game.
  2. Create the script EndUI in the folder Scripts and enter the following code:
using UnityEngine;
using TMPro;

public class EndUI : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI timeText;
    [SerializeField] private TextMeshProUGUI coinText;
    [SerializeField] private TextMeshProUGUI initialSpawnText;
    [SerializeField] private TextMeshProUGUI initialSpeedText;
    [SerializeField] private TextMeshProUGUI currentSpawnText;
    [SerializeField] private TextMeshProUGUI currentSpeedText;

    // Start is called before the first frame update
    void Start()
    {
        float playedTimeMin = Mathf.Floor(GameSettings.instance.GetPlayedTimeInSec() / 60);
        float playedTimeSec = Mathf.Floor(GameSettings.instance.GetPlayedTimeInSec() % 60);
        timeText.text = playedTimeMin.ToString() + "min " + playedTimeSec.ToString() + "sec";
        coinText.text = GameSettings.instance.coins.ToString();
        initialSpawnText.text = GameSettings.instance.GetInitialSpawnTime().ToString() + "sec";
        initialSpeedText.text = GameSettings.instance.GetInitialWorldSpeed().ToString() + "m/s";
        currentSpawnText.text = GameSettings.instance.currentSpawnTime.ToString() + "sec";
        currentSpeedText.text = GameSettings.instance.currentWorldSpeed.ToString() + "m/s";
    }

    public void EndGame()
    {
        #if UNITY_EDITOR
                UnityEditor.EditorApplication.isPlaying = false;
        #endif
        Application.Quit();
    }
}
  1. Attach this component to the GameObject Canvas and fill it with the GameObject Value from the respective GameObject Field.
  2. Select the button and add an EventListener to On Click ().
    • Select Canvas as the object and EndUI > EndGame () as the function.

Connecting all the scenes

  1. Go to scene Start and select the GameObject GameSettings.
  2. In the component Game Settings deactivate Debug State so that the event TestEnd is invoked when the player touches an obstacle.
  3. Go to scene MainScene and create an empty object called "SceneLoader".
  4. Set Position to (0,0,0).
  5. Add the component Scene Loader and enter "End" for Scene to load.

Building the game

  1. Go to File > Build Settings... to open the window Build Settings.
  2. Click on Build and a window will appear
  3. Go to the folder AgilityTest which contains the Maya and the Unity project.
  4. Create a new folder called Build and select it.
  5. Click on the button Select folder to build the game.
  6. Save the project.

Set up OptiTrack

OptiTrack's documentation explains very well the room calibration, skeleton tracking and data streaming.

Room calibration

  1. Open Motive and click on New Calibration.
  2. Remove all the reflections and markers from the scene. Prefer actual removal over using masks since masked areas cannot be tracked.
    • Changing the video type of the camera from Object Mode to Grayscale or MJPEG can help identifying the issue.
    • Only one camera can be in Grayscale. If multiple are changed to this mode, they will all show nothing.
  3. Click Continue.
  4. Click Start Wanding and wand the whole space the player will move.
    • In this project the wand CW-500 in configuration A was used.
    • Do not forget the floor and the height a player can reach with a jump.
  5. Click Start Calibrating.
  6. When you are satisfied with the calibration, click Continue
  7. Place the calibration square to the center of the space the player will move because it will also be the center of the room in the game.
    • Additionally, the positive z-direction should be the wall side where the screen is.
    • The following picture shows where and how the origin should be placed for the game when the playing space is smaller than the real room.

How to set the ground plane when the playing space is smaller than the real room

  1. Click Set Ground Plane
  2. When the ground plane is set, the calibration square can be removed.

Skeleton Tracking

  1. The player should put on the bodysuit.
  2. Open the pane Builder and select for Type Skeleton.
  3. Select Baseline as Template and place the markers according to it on the player.
  4. Enter "Player" as Name. This string will be used for the game.
  5. Click Create

Data streaming

  1. Open the window Setting and navigate to the tab Streaming.
  2. Activate Enable
  3. Remember the address at Local interface. This will be the server and local address for the game.
  4. Transmission Type should be Unicast
  5. Skeletons should be activated
  6. Skeleton Coordinates should be Global
  7. Bone Naming Convention should be Motive
  8. Up Axis should be Y-Axis.

Play the game

  1. Plug the portable drive to the PC/laptop where Motive is.
  2. Navigate to the folder AgilityTest/Build and click on the file AT.
  3. Enter as the server and the local address the address from Local interface in Motive.
  4. Enter "Player" as the skeleton asset name because this is how it was named in Motive.
  5. Fill the player setup.
  6. Enter the size of the space the player will move in as the room size.
  7. Fill the obstacle setup.
  8. Touch StartCube to start the test.
Clip_360.mp4

Clone this wiki locally