GitHub Actions & Unity 3D

This post has been triggered by a rather long succession of failures to get any kind of build working with Unity 3D using my locally hosted runner with GitLab. I’m clearly missing a trick and, let’s be honest, magically grasping hold of a ‘trick’ at 1am probably isn’t the easiest thing to do. So, as I rest by bum between the chair and keyboard once more my mind wanders to the realm of GitHub Actions; does a solution exist in this alternate realm (and, more importantly, will I be able to get it to work!). Let’s see, shall we? πŸ™‚

The Repository

We need a repository and, I didn’t actually know this, but it looks like the standard options for ‘.gitignore’ auto-generated files do include an option for ‘Unity’, so we’ll be selecting that from the get-go. I’ve made the repository private to protect what will surely be awesome source code (or to save from impending embarrassment). The repository name and description will give you a rough idea of what we’re going to create. πŸ™„

Unity Repository Setup
Unity Repository Setup.

Let’s Build a Great Game (no, not really)

Our concept then is ‘big-red-ball’, the best game about a red ball that we can conjure up in not much time at all. I’m going to set up for ‘full-force 3D’ and drop a project in our local repository:

Big Red Ball Setup.
Big Red Ball Setup.

At this point, I realise that I have a project setup fail, of sorts, leading to every file in existence getting caught up as a change:

Structure Issue
Structure Issue.

This is caused by an extra, nested, folder being created on project initialisation. I could toy with the .gitignore but I’m just going to move all of my project files to the root folder of the repository, for ease. Voila, much better!

Project Structure Fixed
Project Structure Fixed.

Here’s the sample game running which will serve as our testbed for a GitHub Action-based build:

Game Running.
Game Running.

Just to ensure the build process works OK with something not quite out-of-the-box I included an additional font asset called ‘Thaleah_PixelFont’. I’ll illustrate the source code at the very end of the post, but it’s not all that relevant to proceedings from this point forward. At this stage, I’ve pushed all code back to the master branch and this is what the repository looks like:

Big Red Ball Repository
Big Red Ball Repository.

We’re ready to start building a GitHub Action that will handle the build of our game and generation of artifacts (and activate a license). It is important to note that only enabled scenes that have been added to the project’s ‘Build Settings’ will be built by default, as shown (either drag the scene from the project tab directly or use the ‘Add Open Scenes’ option):

Project Build Settings
Project Build Settings.

Request an Activation File

I’m going down the route of acquiring a Unity personal license. Click on the ‘Actions’ tab within the repository and then select the ‘set up a workflow yourself’ option.

Setup Workflow Manually
Setup Workflow Manually.

Using the guidance found here I generated the following workflow, which leads to the end game of using the ‘Build Action’ later down the line. Here’s how my Action is laid out, with the only area needing a tweak is the ‘unityVersion’ property (I’ve called the workflow file ‘activation.yaml’ for clarity).

name: Acquire activation file
on: [push]
jobs:
  activation:
    name: Request manual activation file πŸ”‘
    runs-on: ubuntu-latest
    steps:
      # Request manual activation file
      - name: Request manual activation file
        id: getManualLicenseFile
        uses: webbertakken/unity-request-manual-activation-file@v1.1
        with:
          unityVersion: 2019.3.9f1
      # Upload artifact (Unity_v20XX.X.XXXX.alf)
      - name: Expose as artifact
        uses: actions/upload-artifact@v1
        with:
          name: ${{ steps.getManualLicenseFile.outputs.filePath }}
          path: ${{ steps.getManualLicenseFile.outputs.filePath }}

This will likely take a couple of minutes to run. Once this process completes you’ll be left with an artifact (.alf):

Activation Artifact
Activation Artifact.

We can download this and do a little round trip in order to get hold of the relevant ‘secret’ to implant into our repository, with these steps (copied verbatim from the aforementioned link):

  • Download the manual activation file that now appeared as an artifact.
  • Visit license.unity3d.com and upload it.
  • You should now receive your license file (Unity_v20XX.x.ulf) as a download.
  • Open Github > Your repository > Settings > Secrets.
  • Add a new secret called UNITY_LICENSE and copy the contents of your license file into it.

After download, I did just have to unzip the file before uploading it. The process was seamless:

Manual Activation Process
Manual Activation Process.
License Selection
License Selection.
License Download
License Download.

With the ‘.ulf’ file in hand you just need to open it in the text editor of your choice and then, as detailed in the steps previously, add a secret within the repository with the key of ‘UNITY_LICENSE’ and a value based on the contents of this file.

Secret Configured
Secret Configured.

This one-time activation process is now complete! The documentation alludes to this but doesn’t expressly state it, but my gut feeling here was to delete the activation.yaml file to prevent this process from running perpetually on every push. We need the artifact file only once, presumably up until the point that we switch Unity versions.

Preparing a Unit Test

To fully trial out the build/test Action available to us let’s set up one test for our project, just for illustration. Inside Unity, I navigated to ‘Window > General > Test Runner’ and, after making sure I had the ‘Assets’ folder selected, I selected ‘Create Playmode Test Assembly Folder’.

Test Runner Configuration
Test Runner Configuration.

From here, I was prompted to create a test script, which I did.

Create Test Script
Create Test Script.

This auto-generated a new assembly inside the solution called Tests (utilising NUnit), with a ‘NewTestScript.cs’ file with the following content:

using System.Collections;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

namespace Tests
{
    public class NewTestScript
    {
        // A Test behaves as an ordinary method
        [Test]
        public void NewTestScriptSimplePasses()
        {
            // Use the Assert class to test conditions
        }

        // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use
        // `yield return null;` to skip a frame.
        [UnityTest]
        public IEnumerator NewTestScriptWithEnumeratorPasses()
        {
            // Use the Assert class to test conditions.
            // Use yield to skip a frame.
            yield return null;
        }
    }
}

Keeping within the remit of making this as basic as possible, the only change I made was to add mock code to the ‘NewTestScriptSimplePasses’ test, as follows:

// A Test behaves as an ordinary method
[Test]
public void NewTestScriptSimplePasses()
{
	GameObject sut = new GameObject("MyGameObject");
	Assert.AreEqual("MyGameObject", sut.name);
}

Back inside Unity, you can trigger tests using the ‘Run All’ button. As you can see, we’ve gone all green!

Tests Passing
Tests Passing.

This gives us another piece to our puzzle to ensure that any test step in our Action is working as expected.

The Build Action

The documentation and resources surrounding this are excellent. Based on the information discovered here I stubbed out a simple build/test Action. This integrates our ‘UNITY_LICENSE’ secret and makes mention of our Unity version, where required. Other than that, not too much tinkering is required to get this up and running.

name: Actions 😎

on:
  pull_request: {}
  push: { branches: [master] }

env:
  UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}

jobs:
  build:
    name: Build my project ✨
    runs-on: ubuntu-latest
    steps:
    
      # Checkout
      - name: Checkout repository
        uses: actions/checkout@v2
        with:
          lfs: true
    
      # Cache
      - uses: actions/cache@v1.1.0
        with:
          path: Library
          key: Library

      # Test
      - name: Run tests
        uses: webbertakken/unity-test-runner@v1.3
        with:
          unityVersion: 2019.3.9f1

      # Build
      - name: Build project
        uses: webbertakken/unity-builder@v0.10
        with:
          unityVersion: 2019.3.9f1
          targetPlatform: StandaloneWindows64

      # Output 
      - uses: actions/upload-artifact@v1
        with:
          name: Build
          path: build

Notice the use of the ‘cache’ step, which caches ‘Library’ files from previous builds leading to a significant performance boost and lower build times (up to 50% savings, apparently). The ‘output’ step handles how our artifacts are uploaded. As a target platform, I am just locking in on 64-bit Windows, but you can hook this up simultaneously to as many target platforms as you need.

More details specifically about the Unity Builder Action can be found here.

On push (or pull request, as configured) the Action kicks off perfectly, as shown:

Build Action Complete
Build Action Complete.

We have, on inspection of the logs, evidence of our configured tests passing which is nice:

Tests Passing
Tests Passing.

Lastly, the Action did output an artifact that contains a ‘.exe’ and supporting files:

Build Artifact
Build Artifact.

There are more complex ways to configure this kind of Action but it’s pleasantly surprising how easy a simple build, test and artifact upload Action is to configure (even the licensing hurdles are easy enough to bolt over).

An Aside

As promised at the start of the post, here’s the very basic code behind the sample game (XML comments omitted, for brevity):

BidRedBallController

This checks, on update, for any horizontal or vertical movement (GetAxisRaw gets the axis change value without any smoothing, meaning the value will be -1, 0 or 1) and moves the player as required. There’s a multitude of better ways to detect an ‘out-of-bounds’ situation but as I know the respective playing area I’ve included a fixed check, that respawns the player at a start location if they leave the play area.

using UnityEngine;

public class BidRedBallController : MonoBehaviour
{
    public Transform PlayerTransform;
    public float PlayerSpeed = 1f;

    private void Update()
    {
        // Translate the player position (based on speed)
        PlayerTransform.Translate(
            new Vector3(
                Input.GetAxisRaw("Horizontal") * PlayerSpeed * Time.deltaTime,
                0f,
                Input.GetAxisRaw("Vertical") * PlayerSpeed * Time.deltaTime));

        // Basic protection for the player going 'out of bounds'
        if (Mathf.Abs(PlayerTransform.position.x) > 5f || Mathf.Abs(PlayerTransform.position.z) > 5f)
        {
            PlayerTransform.position = new Vector3(
                    GameManager.GameManagerInstance.RespawnPosition.transform.position.x,
                    GameManager.GameManagerInstance.RespawnPosition.transform.position.y,
                    GameManager.GameManagerInstance.RespawnPosition.transform.position.z);
        }
    }
}

CollectableTrigger

This script checks for the player (checked by ‘tag’ assignment) colliding directly with the collectable, aka entering the ‘trigger’ zone. A particle effect is triggered at the location of the collectable and the object is disabled. The player score is then incremented.

using UnityEngine;

public class CollectableTrigger : MonoBehaviour
{
    public ParticleSystem collectedEffect;

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Player"))
        {
            ParticleSystem newEffect = Instantiate(
                collectedEffect,
                new Vector3(gameObject.transform.position.x, gameObject.transform.position.y, gameObject.transform.position.z),
                gameObject.transform.rotation);

            newEffect.Play();

            gameObject.SetActive(false);

            GameManager.GameManagerInstance.IncrementScore();
        }
    }
}

Exit

Nothing particularly fancy here. When the exit objects trigger-based collider is entered by the player it sparks off the game complete process, as handled by the ‘GameManager’ instance. I’ve been trying to get in the habit of ensuring I use ‘.compareTag’ instead of ‘==’, as research suggests a bit of a performance buff when using this method (less CIL instructions are generated when the C# is translated – something I’d like to verify myself later on).

using UnityEngine;

public class Exit : MonoBehaviour
{
    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Player"))
        {
            GameManager.GameManagerInstance.GameComplete();
        }
    }
}

GameManager

This class, with a ‘singleton’ set up for access by other types, forms the hub for game interactions (which suffices for this sample). This consumes some information about the start/exit spawn locations alongside references to UI components. As the player picks up collectables a score is incremented, leading to the exit portal being activated. The player reaching this portal triggers the game complete state (a button is shown to reload the scene). A timer is configured and its value is incremented in the ‘Update’ method, using the inbuilt ‘Unity.deltaTime’ helper.

using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class GameManager : MonoBehaviour
{
    private float currentTime = 0f;
    private bool gameStarted = false;

    [Header("Level Parameters")]
    public int RequiredScore;
    public GameObject ExitPortal;
    public GameObject RespawnPosition;

    [Header("UI")]
    public Button StartButton;
    public GameObject TryAgainButton;
    public Text TimeText;
    public Text ScoreText;
    public Text ExitText;

    // Our core, singleton, GameManager instance
    private static GameManager gameManagerInstance = null;

    // The current score for the player
    private int score;

    public static GameManager GameManagerInstance
    {
        get
        {
            // Create a new GameObject and add a GameManager script to it if it doesn't already exist (allocating this to the gameManagerInstance field)
            if (gameManagerInstance == null)
            {
                gameManagerInstance = new GameObject("GameManager").AddComponent<GameManager>();
            }

            // Return the instance for global use
            return gameManagerInstance;
        }
    }

    public void IncrementScore()
    {
        score++;
        ScoreText.text = $"Score: { score }";

        if (score >= RequiredScore)
        {
            ExitText.gameObject.SetActive(true);
            ExitPortal.SetActive(true);
        }
    }

    public void StartGame()
    {
        score = 0;
        currentTime = 0;

        StartButton.gameObject.SetActive(false);
        Time.timeScale = 1f;
        gameStarted = true;
    }

    public void GameComplete()
    {
        ExitText.gameObject.SetActive(false);
        ExitPortal.SetActive(false);
        Time.timeScale = 0f;
        TryAgainButton.SetActive(true);
    }

    public void TryAgain()
    {
        SceneManager.LoadScene("LevelOne");
    }

    private void Awake()
    {
        // In Unity horrific things happen when you use constructors. To that end, ensure that only one GameManager comes into existence at game start.
        Time.timeScale = 0f;
        gameManagerInstance = this;
    }

    private void Update()
    {
        if (gameStarted)
        {
            currentTime += Time.deltaTime;
            TimeText.text = $"Time: { currentTime:f1}s";
        }
    }
}

As always, I hope this proved helpful and interesting; perhaps even leading you to play around with building your Unity projects using GitHub Actions. Happy coding until the next time.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.