Make a fully functional calculator in Unity not only for VR, Part I

Oct. 24, 2024
Make a fully functional calculator in Unity not only for VR, Part I

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() {
Tags:

No tags.

JikGuard.com, a high-tech security service provider focusing on game protection and anti-cheat, is committed to helping game companies solve the problem of cheats and hacks, and providing deeply integrated encryption protection solutions for games.

Explore Features>>