Coding into the Void

Coding into the Void

A blog that I’ll probably forget about after making thirty-one posts.

Programmatic Menus in Unity Part 2: Unlimited Menus

Previously, we went over how, given a prefab template, how to programmatically generate buttons, sliders, dropdowns, and toggles. This time, we will generate entire menus that you can swap between.

(Spoilers for these blog posts: This is the core of a library I wrote, Menutee, available here.)

Putting Together a Panel

In this parlance, a panel is essentially a menu screen. For instance, an options menu or level select menu would each have their own panel config.

The Config

public class PanelConfig {
    public readonly string Key;
    public readonly string DefaultSelectableKey;
    public readonly PanelObjectConfig[] PanelObjects;

    public PanelConfig(string key, string defaultSelectableKey, 
            PanelObjectConfig[] panelObjects) {
        Key = key;
        DefaultSelectableKey = defaultSelectableKey;
        PanelObjects = panelObjects;
    }

    public class Builder {
        private List<PanelObjectConfig> _panelObjectConfigs 
            = new List<PanelObjectConfig>();
        private string _key;
        private string _defaultSelectableKey;

        public Builder(string key) {
            _key = key;
        }

        public Builder AddPanelObject(
                PanelObjectConfig config, bool defaultObject = false) {
            _panelObjectConfigs.Add(config);
            if (defaultObject) {
                _defaultSelectableKey = config.Key;
            }
            return this;
        }

        public PanelConfig Build() {
            if (_defaultSelectableKey == null) {
                _defaultSelectableKey = _panelObjectConfigs[0].Key;
            }
            return new PanelConfig(_key, _defaultSelectableKey, 
                _panelObjectConfigs.ToArray());
        }
    }
}

A panel is made up of panel objects (like buttons, sliders, and the like). The default selectable determines what object will be selected by default when the menu is open, and what object will be selected after controller input if no item is selected at all. The builder pattern lets us easily set a default selectable when adding it, or take the first element if none was set.

The ordering of the panel objects determines the order of the UI elements in each menu.

The Manager

public class PanelManager : MonoBehaviour {
    public string Key;
    public UIElementManager DefaultInput;

    public void SetPanelActive(bool active) {
        gameObject.SetActive(active);
    }
}

The PanelManager doesn’t contain much—it primarily holds a reference to the input that should be selected by default, and controls whether or not it’s visible. For these reasons, I don’t require that the panel prefab contain a PanelManager instance, and add it in the code. Either approach is fine; it only happens on scene start.

The Full Menu

public class MenuConfig {
	public readonly bool Closeable;
	public readonly bool MenuPausesGame;
	public readonly string MainPanelKey;
	public readonly PanelConfig[] PanelConfigs;

	public MenuConfig(bool closeable, bool menuPausesGame, 
            string mainPanelKey, PanelConfig[] panelConfigs) {
		Closeable = closeable;
		MenuPausesGame = menuPausesGame;
		MainPanelKey = mainPanelKey;
		PanelConfigs = panelConfigs;
	}

	public class Builder {
		private bool _closeable;
		private bool _menuPausesGame;
		private string _mainPanelKey = null;
		private List<PanelConfig> _panelConfigs = new List<PanelConfig>();

		public Builder(bool closeable, bool menuPausesGame) {
			_closeable = closeable;
			_menuPausesGame = menuPausesGame;
		}

		public Builder AddPanelConfig(
                PanelConfig config, bool mainPanel = false) {
			_panelConfigs.Add(config);
			if (mainPanel) {
				_mainPanelKey = config.Key;
			}
			return this;
		}

		public MenuConfig Build() {
			if (_mainPanelKey == null) {
				_mainPanelKey = _panelConfigs[0].Key;
			}
			return new MenuConfig(_closeable, _menuPausesGame, _mainPanelKey, 
                _panelConfigs.ToArray());
		}
	}
}

Like a panel object is made up of multiple panel objects, a menu is made up of multiple panels, with the main panel key determining the menu that will first appear when the menu is shown.

There’s also two additional booleans, which determine whether a menu can be closed, and whether it pauses the game. I tend to have the menu be closeable and pause the game when in-game, and uncloseable and not pase the game when on the main menu.

The Generator

The MenuGenerator class is what generates the whole stack of menus, including the panels and their elements. It should be placed on an existing canvas in conjunction with the MenuManager class, which will come later.

Since this class is a lengthy one, I’m going to split it up and provide commentary as it goes.1

public class MenuGenerator : MonoBehaviour {

    [Tooltip("Canvas element to be used as the container for panels.")]
    public GameObject Parent;

    [Tooltip("Panel prefab that menu prefabs will be placed in.")]
    public GameObject MenuObjectPrefab;
    [Tooltip("Prefab used for the generation of buttons.")]
    public GameObject ButtonPrefab;
    [Tooltip("Prefab used for the generation of sliders.")]
    public GameObject SliderPefab;
    [Tooltip("Prefab used for the generation of toggles.")]
    public GameObject TogglePrefab;
    [Tooltip("Prefab used for the generation of dropdowns.")]
    public GameObject DropdownPrefab;

    public Dictionary<string, GameObject> PanelDictionary 
        = new Dictionary<string, GameObject>();
    public Dictionary<string, Dictionary<string, GameObject>> 
        PanelObjectDictionary 
            = new Dictionary<string, Dictionary<string, GameObject>>();

The instance variables, which are exposed to the inspector in Unity. The Parent game object, unlike the other GameObjects, is a reference to an existing component on the scene, often the canvas itself. The rest are the templates, which will be used as building blocks for the scene. MenuObjectPrefab should contain a vertical layout group so that elements get placed correctly.2

public void CreateMenu(MenuManager helper, MenuConfig menuConfig) {
    helper.MenuConfig = menuConfig;
    List<PanelManager> panels = new List<PanelManager>();
    foreach (PanelConfig panel in menuConfig.PanelConfigs) {
        PanelManager manager = CreatePanel(Parent, panel, menuConfig);
        panels.Add(manager);
    }
    helper.Panels = panels.ToArray();
}

The call to CreateMenu() is made from the class that defines the MenuConfig object, which also lives on the canvas object (or a child thereof).3 It populates MenuManager (explained after this) with a config object, and then instantiates all of the panels.

public PanelManager CreatePanel(
        GameObject parent, PanelConfig config, MenuConfig menuConfig) {
    GameObject prefab = MenuObjectPrefab;
    GameObject obj = Instantiate(prefab, parent.transform);
    obj.name = config.Key;
    PanelManager manager = obj.AddComponent<PanelManager>();
    manager.Key = config.Key;
    PanelDictionary.Add(config.Key, obj);

We allocate the panel prefab object as a child of the parent and name it something that will make sense on inspection. Add a PanelManager to the object, and set the manager’s key to the one in the config. The panel is added to a dictionary by its key, so that it’s easy to switch menus.

    Dictionary<string, GameObject> dict 
        = new Dictionary<string, GameObject>();

    // Use this to set up nav menu. Layout assumed to be vertical.
    List<Selectable> selectableObjects = new List<Selectable>();

    foreach (PanelObjectConfig objConfig in config.PanelObjects) {
        GameObject go = CreatePanelObject(obj, objConfig);
        UIElementManager elementManager = 
            go.GetComponentInChildren<UIElementManager>();
        if (elementManager.SelectableObject != null) {
            Selectable selectable = elementManager.SelectableObject
                                        .GetComponent<Selectable>();
            if (selectable != null) {
                selectableObjects.Add(selectable);
            }
        }

        dict[objConfig.Key] = go;
        if (objConfig.Key == config.DefaultSelectableKey) {
            manager.DefaultInput = elementManager;
        }
    }

Each panel object is created, and a reference to its manager is obtained. The first element of type Selectable (there should only be one per object) is added to a list for later processing, and the default element is set on the panel manager.

    // Hook up navigation with elements with selectable objects.
    for (int i = 0; i < selectableObjects.Count; i++) {
        // Make new one to avoid potential property strangeness.
        Navigation navigation = new Navigation();
        navigation.mode = Navigation.Mode.Explicit;
        if (config.HorizontalMenu) {
            navigation.selectOnLeft = 
                i > 0 ? selectableObjects[i - 1] : null;
            navigation.selectOnRight = i < selectableObjects.Count - 1 ? 
                selectableObjects[i + 1] : null;
            navigation.selectOnUp = null;
            navigation.selectOnDown = null;
        } else {
            navigation.selectOnUp = 
                i > 0 ? selectableObjects[i - 1] : null;
            navigation.selectOnDown = i < selectableObjects.Count - 1 ? 
                selectableObjects[i + 1] : null;
            navigation.selectOnLeft = null;
            navigation.selectOnRight = null;
        }
        selectableObjects[i].navigation = navigation;
    }

    PanelObjectDictionary[config.Key] = dict;
    return manager;
}

This section starts off with setting the Navigation object on all the UI elements.4 It’s how you specify how keyboard and controller directional input interact with the canvas.

It can be generated automatically, but it can run into issues when the selectable inputs are not right above one another (e.g. a left-justified button with a right-justified slider may require a diagonal press to continue). This way, the navigation behaves as expected, no matter how far off to the side the selectable may be.

Panel generation finishes by setting a map of all the panel objects, and returning the manager.

    protected GameObject CreatePanelObject(
        return config.Create(panel);
    }

    ...
}

Here’s the generic panel object creation method, which calls Create() on the PanelObjectConfig object.5 With that, all the menu elements are in place, but there’s no way to use them yet.

A Helping Hand

This class is named MenuManager6. It manages the behavior of the menu and handles player input.7

Let’s break it down.

public class MenuManager : MonoBehaviour {

    private bool _active;
    public BoolVariable Paused;

    [Tooltip("The canvas element. If this is not set, this script will try to get the canvas from the game object it is on.")]
	public Canvas Canvas;

    public PanelManager[] Panels;
    public MenuConfig MenuConfig;
    public EventSystem EventSystem;
    public MenuInputMediator InputMediator;

    protected Stack<string> _panelStack = new Stack<string>();
    
    private PanelManager _activeMenu;
    private UIElementManager _activeDefaultInput;
    private float _cachedTimeScale = 1;
    private CursorLockMode _cachedLockMode = CursorLockMode.Locked;
    private bool _cachedVisible = false;

There are a few instance variables that may stick out to you:

  • The Paused BoolVariable. BoolVariables are references to scriptable objects8 that contain one element of state: a boolean. I’m using it as a flag to indicate whether or not the game is paused.
  • MenuInputMediator, a class of my own design, that acts as a shim layer between my menu classes and the input.9
  • EventSystem, a Unity class that translates input into interactions with the menu.
private void Awake() {
    if (Canvas == null) {
        Canvas = GetComponent<Canvas>();
    }
}

private void Start() {
    // Read Closeable in Start so that other scripts
    // can set it in Awake.
    SetMenuUp(!MenuConfig.Closeable);
}

void SetMenuUp(bool up) {
    _active = up;
    Canvas.enabled = newUp;
    Paused?.SetValue(up);
    if (_active) {
        _cachedLockMode = Cursor.lockState;
        Cursor.lockState = CursorLockMode.None;
        _cachedVisible = Cursor.visible;
        Cursor.visible = true;
        _cachedTimeScale = Time.timeScale;
        Time.timeScale = MenuConfig.MenuPausesGame ? 0 : _cachedTimeScale;
        ActivatePanel(MenuConfig.MainPanelKey);
    } else { // Out of Menu
        Cursor.lockState = _cachedLockMode;
        Cursor.visible = _cachedVisible;
        Time.timeScale = _cachedTimeScale;
        ActivatePanel(null);
    }
}

void ToggleMenu() {
    if (!MenuConfig.Closeable) {
        return;
    }
    SetMenuUp(!_active);
}

public void ExitMenu() {
    if (!_active)
        return;
    ToggleMenu();
}

These methods handle the opening and closing of the menu. It locks and unlocks the mouse cursor (depending on whether it was locked before opening the menu), and pauses time, as configured.10 If opening the menu for the first time, it will surface the default panel (MenuConfig.MainPanelKey).

private void ActivatePanel(string key) {
    ActivatePanel(key, 
        MenuConfig.PanelConfigs.Where(
            p => p.Key == key).FirstOrDefault());
}

MenuConfig.PanelConfigs.Where(p => p.Key == key).FirstOrDefault() is a fancy way of returning the first panel in the PanelConfigs list with the variable Key equal to key, or null if none was found.

private void ActivatePanel(string key, PanelConfig config) {
    PanelManager active = null;
    EventSystem.SetSelectedGameObject(null);
    foreach(PanelManager manager in Panels) {
        manager.SetPanelActive(key == manager.Key);
        if (key == manager.Key) {
            active = manager;
        }
    }
    _activeMenu = active;
    if (active != null) {
        _activeDefaultInput = active.DefaultInput;
        if (_activeDefaultInput != null 
            && _activeDefaultInput.SelectableObject != null) {
            EventSystem.SetSelectedGameObject(
                _activeDefaultInput.SelectableObject);
        }
    } else {
        _activeDefaultInput = null;
    }
}

ActivatePanel is responsible for switching between menu panels. It activates one panel and hides the rest, and sets the selected game object on the EventSystem to the panel’s first selected object.

/// <summary>
/// Goes to a panel, bypassing the stack. Used by push and pop after
/// modifying the stack. Only use if you know what you're doing.
/// </summary>
/// <param name="key">Key of the panel to go to.</param>
protected void GoToPanel(string key) {
    ActivatePanel(key);
}

public void PushMenu(string key) {
    foreach (PanelManager panel in Panels) {
        if (panel.Key == key) {
            _panelStack.Push(key);
            GoToPanel(key);
            return;
        }
    }
    Debug.LogErrorFormat("Cannot push panel {0}! Not in Panels array.", key);
}

public void PopMenu() {
    if (_panelStack.Count > 0) {
        _panelStack.Pop();
    }
    if (_panelStack.Count == 0) {
        GoToPanel(MenuConfig.MainPanelKey);
    } else {
        GoToPanel(_panelStack.Last());
    }
}

private bool IsAtRoot() {
    return _panelStack.Count == 0;
    }

The way I implement menus acts as a stack, with the back button returning to the panel that sent it. Calling GoToPanel() directly bypasses adding to the stack, which is why I advise against using it.

    private void Update() {
        if (InputMediator.PauseDown()) {
            ToggleMenu();
        } else if (InputMediator.UICancelDown()) {
            if (!IsAtRoot()) {
                PopMenu();
            }
        }
        if (_activeDefaultInput != null 
                && EventSystem.currentSelectedGameObject == null 
                && (Mathf.Abs(InputMediator.UIX()) > 0.1 |
                    | Mathf.Abs(InputMediator.UIY()) > 0.1)) {
            EventSystem.SetSelectedGameObject(
                _activeDefaultInput.SelectableObject);
        }
    }
}

In addition to handling showing and hiding menus and popping panels from the stack if the back button was pressed, the Update method handles some of the finer details of controller interactions with menus. If you hover over a menu option with the mouse, that option will become highlighted, and if you remove the mouse from all options, no object will be highlighted or selected.

This is a problem for keyboard and controller input, however, as they require a selected object in order to function. To get around this, I set the default input as highlighted if any movement keys are pressed.

An Example Menu

Now that we have all the infrastructure in place, let’s look at what a config object looks like in a menu with two panels:

public class InGameMenu : MenuGenerator {

	private MenuHelper _manager;

	void Awake() {
		_manager = GetComponent<MenuHelper>();

		MenuConfig.Builder builder = new MenuConfig.Builder(true, true);

        PanelConfig.Builder mainPanelBuilder 
            = new PanelConfig.Builder("Main");

		mainPanelBuilder.AddPanelObject(
            new ButtonConfig.Builder("resume")
                .SetDisplayText("Resume")
                .SetButtonPressedHandler(delegate (ButtonManager manager) {
                    _manager.ExitMenu();
                }).Build())
		mainPanelBuilder.AddPanelObject(
            new ButtonConfig.Builder("options")
                .SetDisplayText("Options")
                .SetButtonPressedHandler(delegate (ButtonManager manager) {
                    _manager.PushMenu("Options");
                }).Build())
        // No point in showing exit button in WebGL - it does nothing.
		if (Application.platform != RuntimePlatform.WebGLPlayer) {
			mainPanelBuilder.AddPanelObject(
            new ButtonConfig.Builder("exit")
                .SetDisplayText("Exit")
                .SetButtonPressedHandler(delegate (ButtonManager manager) {
                    Application.Quit();
                }).Build()), true);
		}
		
        // Add main panel and set as the default panel.
        builder.AddPanelConfig(mainPanelBuilder.Build(), true);

        PanelConfig.Builder optionsBuilder 
            = new PanelConfig.Builder("Options");

		optionsBuilder.AddPanelObject(new ButtonConfig.Builder("back")
			.SetDisplayText("Back")
			.SetButtonPressedHandler(delegate (ButtonManager manager) {
				menuHelper.PopMenu();
			}).Build());
		optionsBuilder.AddPanelObject(
            new SliderConfig.Builder("sliderlook", 0.1f, 3f, 1f)
			.SetDisplayText("Look Speed")
			.SetSliderUpdatedHandler(
                    delegate (SliderManager manager, float newValue) {
				// Handle sensitivity
			}).Build());
		optionsBuilder.AddPanelObject(new DropdownConfig.Builder("quality")
			.SetDisplayText("Quality")
			.AddOptionStrings(QualitySettings.names)
			.SetDefaultOptionIndex(QualitySettings.GetQualityLevel())
			.SetDropdownChosenHandler(delegate (DropdownManager manager, 
                                        int newIndex, string optionString) {
				QualitySettings.SetQualityLevel(newIndex);
			}).Build());
        builder.AddPanelObject(new ToggleConfig.Builder("fullscreen")
			.SetDisplayText("Fullscreen")
			.SetIsOn(Screen.fullScreen)
			.SetTogglePressedHandler(delegate (ToggleManager manager, 
                                        bool newValue) {
				Screen.fullScreen = newValue;
			}).Build());

        builder.AddPanelConfig(optionsBuilder.Build(), true);

		CreateMenu(_manager, builder.Build());
	}
}

It’s verbose, but it gets the job done. Note that the exit button is conditionally omitted based on build platform. The quality dropdown example is a bit eccentric, but it’s a working way to hook into Unity’s quality options.

Conclusion

With these classes, you should have all that you need to set up programmatic menus in Unity. In the next post, I’ll extract the cursor and timescale management out of this class, and into one that can handle multiple canvases at the same time.


  1. Am I learning how to write more effectively? Nah. ↩︎

  2. A horizontal layout is trivial to add (just tweak the navigation object on each UI element to go left and right instead of up and down). ↩︎

  3. An example will be later in this post. ↩︎

  4. Unity documentation here. ↩︎

  5. Covered in the last post. ↩︎

  6. This used to be named MenuHelper, but I standardized it to MenuManager for consistency’s sake. So if you see any lingering references to MenuHelper or helper, that’s why. ↩︎

  7. For flexibility’s sake, it might be better to split out the player input, but I have a mechanism that abstracts inputs away for me that I’ll explain later. ↩︎

  8. A ScriptableObject is a data class that Unity supports. Instances of scriptable objects can be saved to your asset folder, and they can be assigned to scripts in the editor. Their variables, if changed, last for the entire lifecycle of the app. ↩︎

  9. I did this because I use Rewired (another input system) for input, but it’s a asset store package and as far as I know, asset store packages cannot be depended on by other packages. This way I can have my scripts in my own package without having a bunch of errors immediately when I import it. It’s a really simple class, so I didn’t bother to include it here; besides, it’s not really in scope for the post. ↩︎

  10. Some people believe that you should never set Time.timeScale to zero, but I’ve never heard a problem with it that didn’t involve a divide by Time.timeScale somewhere in one of a script. ↩︎