Unity Single Scene Architecture - Menu

Unity Single Scene Architecture - Menu

Series Post 4

This is a post in a series about building a game in Unity using a single scene.

Unity Single Scene Architecture Series

I will follow the same pattern of the previous post in which we built a loading screen as an Addressable asset along with tests. If you didn't read the previous post, I recommend doing so.

Framing Out the Menu Screen

Let's start by framing out the game objects for the menu screen.

  • Add a child empty game object underneath "UI" named "MenuController"
  • Add a child to "MenuController" from the context menu UI > Canvas named "MenuCanvas"
  • Change the Canvas Scaler component to Scale With Screen Size with ReferenceResolution set to 1920 x 1080
  • Add a child to "MenuCanvas" from the context menu UI > Panel
  • Set the Image component Color to black with no transparency
  • Add a child to "Panel" from the context menu UI > Text - Text Mesh Pro
  • Set the Rect Transform to middle stretch. set the Y position to 100
  • Set the text to "Single Scenery", center the horizontal and vertical alignments, set font size to 200
  • Add another child to "Panel" from the context menu UI > Button - Text Mesh Pro
  • Set the width to 300, height to 100, Y position to -200
  • Set the Text to "PLAY" with font size 64

You should have a menu screen that looks like this:

image.png

Menu Events

Unlike the loading screen our menu will have one or more interaction components like buttons. A Unity UGUI Button component exposes an OnClick event, that is often wired up in the editor. The problem with this approach is that this event will be handled by a parent object somewhere in the scene and I don't want a child to directly reference a parent, which could easily be the beginning of a spider web of dependencies.

Instead let's create a Menu component that will serve as a design time way to collect these interactable components together and can be accessed by a parent at runtime to wire up these events programmatically.

Create a new script in the "UI" folder called "Menu":

using UnityEngine;
using UnityEngine.UI;

namespace SingleScenery
{
    public class Menu : MonoBehaviour
    {

        [SerializeField] private Button playButton;

        public Button PlayButton => playButton;

    }
}

Drag this script on the "MenuCanvas" game object and select the button in the PlayButton field in the Inspector. Now drag the MenuCanvas into the "Bundles" folder to make it a prefab and delete it from the scene. Mark MenuCanvas as Addressable using the address "MenuCanvas"

image.png

A Note About Conventions

Why not just public Button PlayButton? The reason I use these conventions is to add guard rails for other developers or myself in a week when I've forgotten about this code. I always use [SerializeField] private for fields intended to be set in the editor, which is a Unity mechanism for dependency injection at design time. By making runtime access to this same field a public, read-only property then no one can mistakenly think this should be set at runtime, and the property will not be serialized or show up in the editor.

MenuController

Other than the Menu component, this prefab asset is almost identical to the LoadingController, and if we write new code for the MenuController, it will look almost identical. When you see duplicate code it's a great indicator to refactor.

Inheritance or Composition?

To refactor we have different ways to reuse the identical code. We could create a base class for all of the common code, then inherit from that base class and customize per "controller". Or we could follow the principal of composition over inheritance, which means we'd put the identical code in a controller component, then add a new component for the button related code.

In this case, I feel like inheritance is the best way to go, but remember these approaches, especially if inheritance starts becoming complicated. Then it may be time to switch to composition and creating several smaller components instead.

UIController Base Class

Let's create a new script in the UI folder called "UIController" with the following code.

using System.Collections;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

namespace SingleScenery
{
    public class UIController : MonoBehaviour
    {
        [SerializeField] private AssetReference canvasPrefab;

        public bool Ready => _ready;
        private bool _ready;

        private GameObject _canvas;

        public void Load()
        {
            StartCoroutine(LoadAsync());
        }

        public IEnumerator LoadAsync()
        {
            var handle = Addressables.InstantiateAsync(canvasPrefab, transform);

            yield return handle;  // wait for async call completion

            if (handle.Status == AsyncOperationStatus.Succeeded)
            {
                _canvas = handle.Result;
                _canvas.SetActive(false);
                _ready = true;
            }
        }

        public void Unload()
        {
            _ready = false;
            Addressables.ReleaseInstance(_canvas); // will decrement refence count
        }

        public void Show()
        {
            if (_ready)
            {
                _canvas.SetActive(true);
            }
        }

        public void Hide()
        {
            if (_ready)
            {
                _canvas.SetActive(false);
            }
        }
    }
}

Now our new MenuController script can be as simple as:

using UnityEngine;

namespace SingleScenery
{
    public class MenuController : UIController
    {
        [SerializeField] private Menu menu;

        public Menu Menu => menu;

    }
}

MenuController now has the same API as the LoadingController, with the addition of exposing its interactable menu components for the future wiring up of events.

Refactor LoadingController

Don't forget, we need to go back and apply our refactoring to the loading controller, which is simple as this:

namespace SingleScenery
{
    public class LoadingController : UIController
    {
    }
}

Now let's go configure these changes in the editor:

  • Drag the new MenuController script onto the game object in the scene
  • Drag the MenuCanvas prefab into the Canvas Prefab field of MenuController
  • Drag the MenuCanvas prefab into the Menu field of MenuController (Unity will find/assign the Menu component)
  • Drag the MenuController game object from the scene into the "Bundles" folder to make it a prefab
  • Mark the prefab as Addressable with address "MenuController"

image.png

Loading Controller

We refactored and made some significant changes, did we break anything? Does LoadingController still work? Run your tests from the previous post!

image.png

Oops. Yeah we broke it. Select the LoadingController prefab in the Project and you'll see in the inspector that the Canvas Prefab field is now empty. That's because we changed the name of this field when replacing the controller code with the inherited version. Assign the LoadingCanvas again then rerun the tests.

image.png

Everything should pass now, and while the test code is a pain, it probably just saved time investigating a bug or breaking the build for other team members. You're going to refactor a LOT. Investing in tests will help you keep momentum and not lose time with head scratching over bugs.

Tests

Speaking of tests, let's add more to cover our new code. Add a new test script "MenuController_Tests":

using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.TestTools;
using UnityEngine.UI;

namespace SingleScenery
{
    public class MenuController_Tests
    {
        // Test Settings

        const string ADDRESS = "MenuController";
        const float DELAY = 3f;

        WaitForSeconds delay;
        MenuController controller;
        bool _setup;

        bool _playClicked;

        [OneTimeSetUp]
        public void OneTimeSetUp()
        {
            if (GameObject.FindObjectOfType<Camera>() == null)
            {
                new GameObject("Camera").AddComponent<Camera>();
            }

            delay = new WaitForSeconds(DELAY);
        }

        [UnitySetUp]
        public IEnumerator Setup()
        {
            if (_setup) yield break;

            var handle = Addressables.InstantiateAsync(ADDRESS);
            yield return handle;  // wait for async call completion

            Assert.That(handle.Status == AsyncOperationStatus.Succeeded);
            Assert.IsNotNull(handle.Result);

            controller = handle.Result.GetComponent<MenuController>();

            Assert.IsFalse(controller.Ready);

            _setup = true;

            Debug.Log("Setup: controller is instantiated in test scene");
        }

        [OneTimeTearDown]
        public void TearDown()
        {
            if (controller != null)
            {
                Addressables.ReleaseInstance(controller.gameObject);
            }

            Debug.Log("Teardown: controller reference is released");
        }

        [UnityTest, Order(1), Timeout(5000)]
        public IEnumerator Step_1_Load()
        {
            controller.Load();

            yield return new WaitUntil(() => controller.Ready);

            var canvas = controller.gameObject.GetComponentInChildren<Canvas>(true);

            Assert.That(controller.transform.childCount > 0);
            Assert.IsNotNull(canvas);
            Assert.IsFalse(canvas.gameObject.activeInHierarchy);

            Assert.IsNotNull(controller.Menu);
            Assert.IsNotNull(controller.Menu.PlayButton);

            Debug.Log("Test: controller load passed");
            yield return delay;
        }

        [UnityTest, Order(2)]
        public IEnumerator Step_2_Show()
        {
            controller.Show();

            var canvas = controller.gameObject.GetComponentInChildren<Canvas>(true);
            Assert.IsTrue(canvas.gameObject.activeInHierarchy);

            Debug.Log("Test: controller show passed");
            yield return delay;
        }

        [UnityTest, Order(3)]
        public IEnumerator Step_3_Wire_Up_Button()
        {
            controller.Menu.PlayButton.onClick.AddListener(OnPlayClicked);
            controller.Menu.PlayButton.onClick.Invoke();
            controller.Menu.PlayButton.onClick.RemoveListener(OnPlayClicked);

            Assert.IsTrue(_playClicked);

            Debug.Log("Test: controller button was clicked and handled");
            yield return delay;
        }

        private void OnPlayClicked()
        {
            _playClicked = true;
        }

        [UnityTest, Order(4)]
        public IEnumerator Step_4_Hide()
        {
            controller.Hide();

            var canvas = controller.gameObject.GetComponentInChildren<Canvas>(true);
            Assert.IsFalse(canvas.gameObject.activeInHierarchy);

            Debug.Log("Test: controller hide passed");
            yield return delay;
        }

        [UnityTest, Order(5)]
        public IEnumerator Step_5_Unload()
        {
            controller.Unload();
            yield return delay;
            Assert.That(controller.transform.childCount == 0);
            Debug.Log("Test: controller unload passed");
        }

    }

}

Yes, a lot of this code is duplicated from the LoadingController_Tests, but that's for another day and another post. Go back to TestRunner and Run All, everything should pass.

image.png

You can also follow along in the Addressables Event Viewer to see the loading and unloading of assets.

Summary

We've added a menu to the game, reused the patterns from the loading screen and done some light refactoring to reduce duplicate code. In addition, our tests saved us from committing a broken prefab to source control.

Source Code for this Post:

Github

Next

Follow along to the next post in the series.

Unity Single Scene Architecture Series

If you're finding value in these posts, please leave a comment below to let me know (it's a lot of work) and share on your social media for others. Thanks for reading!

Did you find this article valuable?

Support Steve Mcilwain by becoming a sponsor. Any amount is appreciated!