In this first part of the three-part tutorial, I'll show you how to build a fully functional calculator prefab in Unity. You will learn how to animate a button press using coroutines, work with Unity events and utilize TextMesh Pro.

In the second part, you'll learn how to evaluate mathematical expressions. We'll build a tokenizer, recursive descent parser, and abstract syntax tree (AST) together. If this is your first time encountering these terms, you are about to learn something very neat. At the end the second part, our calculator will be fully functional.

In the third and final part, we will port our calculator to a VR environment. I'll show you how to work with the OpenXR, XR Interaction Toolkit, and XR Hands plugins and you'll learn how to create an object that is both grabbable and interactable.

A fully functional VR calculator in Unity; the end goal of this tutorial series.

Preparing a new Unity project

If you want to follow along, create a new Unity project using a core 3D template. I recommend using Unity 2021.3.24f1. While it should work with newer versions as well, 2021.3.24f1 is the safest bet for compatibility.

I have also prepared a starter package for you, which includes meshes, textures, materials, and a prefab with an assembled calculator using these assets. Download calc.unitypackage from here and import it to your project.

Locate the Calc.prefab asset and place it into your scene. Once you have done that, you should be immediately prompted to import the TextMesh Pro package. In the TMP Importer window, click on "Import TMP Essentials".

Finally, expand the Calc game object in the Hierarchy tab. Select the Display child object and assign the Main Camera to the Event Camera property of the Canvas component.

Note that the Render Mode is set to World Space. For more information about creating a World Space UI, you can refer to the Creating a World Space UI page in the Unity Docs.

In our particular case, since our display won't be receiving any UI events, we don't need to assign an Event Camera. However, it is generally a good practice to ensure that our project does not have any warnings.

Before we start coding, let's take a brief look at the Calc prefab. It's composed of a CalcBase object, which consists of a CalcBase mesh and a BoxCollider. In addition, prefab has twenty individual buttons, each with a CalcButton mesh and also a BoxCollider. Last but not least, it has the Display child object, which, in turn, contains the Text (TMP)  child object.

Each button in the prefab has its own material, but they all share the same CalcButtons.png texture. The texture contains various symbols, and each button displays a different symbol by using different UV coordinates.

The Display child object consists mainly of a Canvas component, and its child object Text (TMP) has a TextMesh component that we will use to display expressions and results.

Key Script


With our assets prepared, let's dive into implementing the logic. We will start by writing a Key script that will define the behaviour of an individual button for our calculator. First, let's add a couple of member variables.

using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Events;

public class Key : MonoBehaviour
{
    [SerializeField]
    private char token;

    private float pushOffset = 0.25f;
    private float movementSpeed = 1.5f;

    private bool isPressed;

    private Vector3 targetPosition;
    private Vector3 initialPosition;

    public UnityEvent<char> OnPressed;
Encapsulating member variables improves data integrity and protects them from direct manipulation, promoting better code maintainability and reducing potential bugs.

To hold a token for each individual key in the Inspector, we will create a private member variable. Instead of making it a public property and breaking the principle of encapsulation, we will use the [SerializeField] attribute to expose it to the Inspector.

The pushOffset sets the distance our key moves within the base when pressed, while the movementSpeed sets how quickly this movement occurs.

The isPressed flag helps us to prevent our button from being repeatedly pressed while a current press is still in progress

At the beginning of a push, a current position will be assigned to the initialPosition and the targetPosition will be calculated based on the initialPosition and pushOffset.

The OnPressed Unity Event will be triggered once the key reaches its target position, passing the token as an event argument. Now, let's write our Press method.

public void Press()
{
    if (isPressed)
        return;

    isPressed = true;

    initialPosition = transform.position;
    targetPosition = new Vector3(transform.position.x, transform.position.y - pushOffset, transform.position.z);

    StartCoroutine(MoveTo(targetPosition, MoveBack, true));
}
A Coroutine in Unity is a special type of function that accepts another function and allows for the execution of code over multiple frames. If this is the first time you've encountered a coroutine, I highly recommend you to read about Coroutines in Unity docs first before you'll proceed with this tutorial.

To prevent the method from being called repeatedly, we implemented a simple safeguard at the beginning of the Press method. After that, we'll set the initial and target positions as previously mentioned, and start a coroutine to initiate the movement.

Let's implement the MoveTo method that we're passing to StartCoroutine.

private IEnumerator MoveTo(Vector3 position, Action onComplete, bool invokeOnPressed)
{
    while (Vector3.Distance(transform.position, position) > 0.001f)
    {
         transform.position = Vector3.MoveTowards(transform.position, position, movementSpeed * Time.deltaTime);
        yield return new WaitForEndOfFrame();
    }

    onComplete?.Invoke();

    if (invokeOnPressed)
    {
        OnPressed?.Invoke(token);
    }
}
Avoid comparing positions with the equality operator, due to rounding errors you may encounter in floating-point calculations. Instead, utilize a tolerance-based comparison (Vector3.Distance(positionA, positionB) > acceptableError) to compare positions within an acceptable range of error. 

First, we move the game object towards its target position, waiting in each iteration of the while loop until the end of the current frame. To ensure smooth and consistent movement regardless of the frame rate, we multiply our movementSpeed by Time.deltaTime.

After our game object reaches its target destination, with an acceptable error margin of 0.001f, we check if the onComplete delegate is not null using the null-conditional operator (.?). If it is not null, we invoke the delegate.

You can read more about Action delegates at learn.microsoft.com/en-gb/dotnet/api/system.action.

If the invokeOnPressed flag is set to true and the OnPressed delegate is not null, we invoke it with the token parameter. Later on, we will wire this from our Display script, to receive and print tokens associated with individual keys.

Note that we passed the MoveBack method as the onComplete argument. The MoveBack is simple, and mostly reuses existing logic.

private void MoveBack()
{
    StartCoroutine(MoveTo(initialPosition, Unlock, false));
}

We run the MoveTo method again as a coroutine, but this time we're moving back to the initial position and invoking the Unlock method upon completion. However, we don't want to invoke the OnPressed delegate this time.

The last piece of a puzzle is the Unlock method, which simply sets isPressed back to false.

private void Unlock()
{
    isPressed = false;
}

If you're a beginner programmer, you might be a bit confused now. If so, just slowly read the code and go line-by-line as you'd be a computer executing it.

This concludes our Key script. Now, get back to Unity Editor and attach it to all child objects of Calc game object which name starts with CalcButton.

Now, go through each button that represents numbers from 0 to 9, as well as symbols +, -, *, /, ^, (, ), and ., and assign a corresponding character as the value of the Token property in the Inspector.

Assign ^ as a value of Key.token on CalcButton_Pow. CalcButton_Div, _Mul, _Sub, _Add, _LeftBracket, _RightBracker, and _Dot get /, *, -, +, (, ) and . respectively.

Key script on CalcButon_EraseOne and CalcButton_Eval does not need any token.

Display script

Now, create a new script and attach it to the "Display" child object. Let's begin by defining a few member variables.

using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;

public class Display : MonoBehaviour
{
    private TextMeshProUGUI textMesh;
    private bool inFaultState = false;
    private bool lastOpIsEvaluation = false;

Boolean variables, inFaultState and lastOpIsEvaluation, will be useful for clearing the display when the backspace key (←) is pressed in a different cases:

  1. When our future parser throws an exception due to an invalid expression like 2+*2, the inFaultState variable will be set to true and display shows "Invalid Syntax". Pressing the backspace key in this situation will clear the entire display.
  2. When the last operation was evaluation, such as typing 256+256 and pressing =, resulting in 512 displayed, pressing the backspace key will clear the entire display.
  3. When you type, for example digits 5, 1 and 2 and press backspace, only the last digit will be removed, resulting in 51 displayed.

In the Awake function, we will assign a reference to the TextMeshProUGUI component from the child object.

private void Awake()
{
    textMesh = GetComponentInChildren<TextMeshProUGUI>();
}

Now, let's implement a Type method. This function will be invoked on the OnPressed events of keys that have tokens.

public void Type(char c)
{
    lastOpIsEvaluation = false;

    if (inFaultState)
    {
        Clear();
        inFaultState = false;
    }

    if (textMesh.text == "0" && IsNumberOrBracket(c))
    {
        textMesh.text = string.Empty;
    }

    textMesh.text += c;
}

Before appending a new character to our display, we always reset the lastOpIsEvaluation flag. Following that, we check if the inFaultState flag is true. If it is, we clear the display and reset the inFaultState flag.

Finally, if the current text on the display is 0 and the input character is a number or a bracket, we first wipe everything from the display before appending the input character.

That's because we want to prevent undesired concatenation of characters. If we didn't perform the clear action in this scenario, we might end up with 01 or 0( on the display, which is not the intended behaviour.

However, in the case of the input character being -, we want to allow it to be appended to the 0 on the display. For example, if we press -, followed by another digit like 2, we get 0-2, which is a valid expression.

Let's now implement helper methods, Clear and IsNumberOrBracker.

private void Clear()
{
    textMesh.text = "0";
}

private bool IsNumberOrBracket(char c)
{
    return double.TryParse(c.ToString(), out _) || c == '(' || c == ')';
}
The double.TryParse method returns false when the parsing to double fails, indicating that the input is not a valid number.

Our Display class requires two additional methods. One of these methods will be invoked by the OnPressed event from a Key that is attached to the CalcButton_Backspace.

public void EraseOne()
{
    if (inFaultState || lastOpIsEvaluation || textMesh.text.Length == 1)
    {
        Clear();
        return;
    }

    textMesh.text = textMesh.text.Remove(textMesh.text.Length - 1);
}

If our calculator is in a fault state, the last operation was an evaluation, or there's only one symbol displayed, we call the Clear method, which sets the display text to 0, and then return early. In other cases, we remove the last character that was added.

The last method, which will be invoked by the OnPressed event from a Key attached to the CalcButton_Eval, will be implemented in the next part. For now, just outline the method like this.

public void Evaluate()
{
    //TODO: implement in the second part of this tutorial
}

Now, let's return to the Unity Editor. Select the CalcButton_Eval object, and in the Inspector, click on the plus icon on the Key component under the OnPressed (Char) event. Drag and drop the Display child object into the object slot. On the right side, select the Display.Evaluate function from the selector.

When we press = key on our calculator, the Display.Evaluate function will be called.

Next, repeat the same step for the CalcButton_EraseOne, but select the Display.EraseOne function instead. For all the other keys, select the Display.Type function in the same manner.

When we press key on our calculator, the Display.EraseOne function will be called.
When we press a digit or symbols +, -, *, /, ^, ., the Display.Type function will be called, passing the value of the Token as an argument.

Testing

To interact with the calculator and test if keys work, there are more approaches you can take. One way is to add an OnCollisionEnter method to the Key script, which calls the Press function when a collision occurs:

private void OnCollisionEnter(Collision col)
{
    Press();
}

Then you can add a Cube game object with a BoxCollider and Rigidbody components and let it fall onto the keys to trigger the collision events.

For a more sophisticated approach, you can write a Raycaster script that sends a ray from screen space to world space. When the ray hits an object with the Key script attached to it, it will call the Press method.

using UnityEngine;

public class Raycaster : MonoBehaviour
{
    Camera mainCamera;

    private void Start()
    {
        mainCamera = Camera.main;
    }

    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
            if (Physics.Raycast(ray, out RaycastHit hit))
            {
                Key key = hit.collider.gameObject.GetComponent<Key>();
                if (key)
                {
                    key.Press();
                }
            }
        }
    }
}
Invoking Camera.main internally triggers the GameObject.Find method, which can be relatively expensive as it iterates over the hierarchy. To improve performance, it's better to call Camera.main once in the Start method and then store a reference to the camera for subsequent use in the Update method.

Attach the Raycaster script to a new game object in your scene or to the Main Camera and you will be able to click on calculator keys with left mouse button.

That's all for today. I hope you've enjoyed the first part of this tutorial. See you in part two, where we'll work on making our calculator fully functional.