So first, let’s explain what this will not cover. It does not cover the basics of creating UI elements inside of Unity. I expect this post will be overly long even without covering how to position, anchor, and style UI elements in Unity. And I also wouldn’t want to waste your time when it’s been explained ad nauseam by many other people. There are plenty of great tutorials on youtube that you can follow for that. Brackeys is a good source of information for that. You can click here to view one of those.
What I’m going to cover is the basics of displaying, filling out, and continuing from a game over UI from a code perspective through the context of Lamina Island’s Alchemy Minigame prototype.
Let’s start with the base class that we use for all of our panels here at Hundred Hour Club.
using System;
using UnityEngine;
using UnityEngine.UI;
public class UnityPanel : MonoBehaviour
{
[SerializeField]
private Selectable firstSelectable;
private UnityPanel previousPanel;
public void SwitchTo(UnityPanel newPanel)
{
//turn off this panel
gameObject.SetActive(false);
newPanel.previousPanel = this;
newPanel.Show();
}
public void GoBack()
{
SwitchTo(previousPanel);
}
public void Show()
{
gameObject.SetActive(true);
if (firstSelectable != null)
{
firstSelectable.Select();
}
}
public void Hide()
{
gameObject.SetActive(false);
}
}
If you are just building your code library for game’s I’d suggest using this as a starting point. If you already have one, then I’d suggest extending it to at least do the bare minimum that this particular panel class does.
The point of having a base panel class is to only have to write the common code for showing and hiding them and switching between them one time. Most tutorials I’ve seen online, such as the Brackey’s one will do a great job of telling you to set the panel to active or disable it appropriately, but then when you have to make the next panel, you’ll just be redoing all of that.
Most of this code should be fairly self explanatory, but there are few quick things worth pointing out.
We have a serialized ‘firstSelectable’ that allows you to pick whatever element you want to start focus on in the UI, it can be extended for ‘On Validated’ to find the first selectable in the hierarchy that is a child of the panel.
The second thing worth noting is that we do NOT use the c# null check when calling the select method here.
if (firstSelectable != null)
{
firstSelectable.Select();
}
I do a full if statement instead of the c# shorthand.
firstSelectable?.Select();
And this is intentional. Not to get too far into it here, but when a game object is “Destroyed” it’s not technically killed in memory immediately, so the value is not technically null even though for the intents and purposes of a game, it’s non existent. Unity has overwritten the “==” & “!=” so you are not really checking for ‘is null’ when you do that, but rather checking ‘is still in working memory’ basically. (This is a really rough explanation, and whether it was a good idea or not to override the == operator is a heated debate, you can read more here).
Unity however can’t override operations like “?.” which means that if you use that instead of “==” you are technically getting different behavior. It’s just worth knowing, before what seems like an innocent code format change leads to an unexpected bug.
Our “Switch To” function maintains a reference to the previous panel for the sake of the “Go Back” feature. This is useful for multi-leveled menus. Such as a Pause Menu that opens up an Options menu & the options menu can just use the “Go Back” function to kick back to the previous menu.
So we use this by simply deriving from the panel as needed. Here is what our Alchemy Minigame Prototype looks like.
public class EndGamePanel : UnityPanel
{
#region P1
private const string P1 = nameof(P1);
[SerializeField]
[BoxGroup(P1)]
private TextMeshProUGUI p1DeathCnt;
[SerializeField]
[BoxGroup(P1)]
private TextMeshProUGUI p1IngredientCnt;
[SerializeField]
[BoxGroup(P1)]
private TextMeshProUGUI p1RecipeCnt;
[SerializeField]
[BoxGroup(P1)]
private TextMeshProUGUI p1RadicalCnt;
#endregion P1
#region P2
private const string P2 = nameof(P2);
[SerializeField]
[BoxGroup(P2)]
private GameObject p2Group;
[SerializeField]
[BoxGroup(P2)]
private TextMeshProUGUI p2DeathCnt;
[SerializeField]
[BoxGroup(P2)]
private TextMeshProUGUI p2IngredientCnt;
[SerializeField]
[BoxGroup(P2)]
private TextMeshProUGUI p2RecipeCnt;
[SerializeField]
[BoxGroup(P2)]
private TextMeshProUGUI p2RadicalCnt;
#endregion P2
}
Now a couple things worth mentioning here.
One is that we are using the “BoxGroup” attribute from OdinInspector. Which is an amazing editor upgrade in unity, that just makes it easier to work with. You can click here if your interested in it.
The second thing to note, is this line.
private const string P1 = nameof(P1);
This is a simple way of making it so that in all the attributes that are requiring a string, it’s written one time what said string is, and then we just use the constant as needed. Less chances of a typo.
So now we have the basics of what we have our end game panel. Let’s get to explaining our EndGameManager before we spend time showing the code to fill out the fields.
public class AlchemyEndGameManager : Singleton<AlchemyEndGameManager>
{
[SerializeField]
private EndGamePanel endGamePanel;
private AclhemyPlayerStatistics p1Statistics = new AclhemyPlayerStatistics();
private AclhemyPlayerStatistics p2Statistics = new AclhemyPlayerStatistics();
private AlchemyGameStatistics gameStatistics = new AlchemyGameStatistics();
}
I’m not going to be covering singletons here, and it’s an not a necessary part of the process. What our end game manager needs is a reference to our end game panel & the game’s statistics.
You can ignore the game statistics in this example since that’s specific to our game. These are just simple DTOs for For a simple game over screen, these statistics might be boiled down to something as simple as a ‘time’ or a ‘score’.
So the logic of the game flow sequence works like this. The Game Manager keeps track of your statistics & handles the end game sequence. The panel simply displays the statistics. And the last piece of the puzzle is what fills the game statistics. For our mini game, that is a third class we call our EventManager.
public class AlchemyEventManager : Singleton<AlchemyEventManager>
{
public class LocalEvents
{
//difficulty events
public UnityEvent IngredientSpawned { get; } = new UnityEvent();
public UnityEvent SucessfullyCreatedResidue { get; } = new UnityEvent();
public UnityEvent FailedToCreateResidue { get; } = new UnityEvent();
//player statistics
public UnityEvent<int> PlayerDied { get; } = new UnityEvent<int>();
public UnityEvent<int> PlayerUsedIngredient { get; } = new UnityEvent<int>();
public UnityEvent<int> PlayerBrewedPotion { get; } = new UnityEvent<int>();
public UnityEvent<int> PlayerCollectedRadical { get; } = new UnityEvent<int>();
//game statistics
public UnityEvent<RecipeSO> PotionBrewed { get; } = new UnityEvent<RecipeSO>();
}
public LocalEvents Events { get; } = new LocalEvents();
}
Using an event manager, is beneficial by decoupling the event itself from what needs to listen to it. We do this instead of directly editing the values because multiple things (Our difficulty manager for example) listen to these events to handle. The above is our standard way of implementing an event manager, but it’s worth studying the why’s and how’s of event managers and figuring out what works best for you. You can learn more about an event manager here. I’ll cover our event system standard and why in a later post.
So anytime any of these events happen. The code will call the event manager and fire off the relevant event. The end game manager needs to listen to said event, to be able to record the statistics accordingly, so let’s cover that real quick.
AlchemyEventManager.LocalEvents events;
public void Start()
{
AttachEvents();
}
protected override void OnDestroy()
{
DetachEvents();
base.OnDestroy();
}
private void AttachEvents()
{
events = AlchemyEventManager.Instance.Events;
events.PlayerDied.AddListener(OnPlayerDied);
....
}
private void DetachEvents()
{
events.PlayerDied.RemoveListener(OnPlayerDied);
...
events = null;
}
private void OnPlayerDied(int playerIndex)
{
if (playerIndex == 0)
{
p1Statistics.DeathCnt++;
}
else
{
p2Statistics.DeathCnt++;
}
}
So there are definitely a few things to cover here.
For one, we keep a local reference to the events because when we change scenes, we don’t know the order that unity will ‘destroy’ them. And just because the object is destroyed doesn’t mean the event was removed, so we we need to remove the event even if the object is already destroyed.
Another is that we attach and detach events in their own methods instead of directly in the start/destroy functions. This is standard to make it clean and easy to follow.
We make a few assumptions in this class because it’s a singleton and we know it’s never going to change states throughout it’s lifetime that are worth pointing out. If this was an object that was enabled/disabled via a pooling system, we would be calling the attach/detach functions on enable/disable. We would normally call detach in the first line of the attach method to prevent double attaching and lastly a null check would be applied to the start of the detach event method.
We are only covering one event here, because they are all basically the same thing, and understanding how to attach to one event is enough to understand how to extend upon it.
So now we have the event manager defined, we are storing the statistics in our game manager, and we have a panel to display said statistics. We just need to trigger our end game sequence, and fill out the statistics. So inside our EndGameManager we need to add an end game sequence.
public class AlchemyEndGameManager : Singleton<AlchemyEndGameManager>
{
...
public void BeginEndGameSequence()
{
endGamePanel.SetPlayer1Statistics(p1Statistics);
endGamePanel.SetGameStatistics(gameStatistics);
bool isPlayerTwoActive = AlchemyLevelManager.Instance.IsTwoPlayer;
endGamePanel.SetPlayer2PanelActive(isPlayerTwoActive);
endGamePanel.SetPlayer2Statistics(p2Statistics);
Time.timeScale = 0;
endGamePanel.Show();
}
}
So this pushes our statistics from here to our panel. Tells the panel to activate player two’s panel if necessary (because our game is co-op. the end game panel looks a little different if it’s single player vs co-op).
Now what triggers your end game sequence is entirely up to you and your game. Maybe it was the player’s death that triggered it, maybe it was a time limit or out of lives. For us it was the player running out of recipes they could actually make using the ingredients they had left. And that will trigger this function to kick off the end game process.
We freeze the game by doing the usual time scale to 0 shenanigans. And we call our show method on the end game panel.
On our end game panel we fill out the statistics simply by setting the text values of our text mesh pro.
public class EndGamePanel : UnityPanel
{
...
public void SetPlayer1Statistics(AclhemyPlayerStatistics statistics)
{
p1DeathCnt.text = statistics.DeathCnt.ToString();
p1IngredientCnt.text = statistics.IngredientCnt.ToString();
p1RecipeCnt.text = statistics.BrewCnt.ToString();
p1RadicalCnt.text = statistics.RadicalCnt.ToString();
}
...
}
And our set panel active is just as simple
public void SetPlayer2PanelActive(bool isPlayerTwoActive)
{
p2Group.SetActive(isPlayerTwoActive);
}
So now we have covered getting the player statistics, storing them, and displaying them in an end game screen. All that’s left is to actually end the game.
On our ‘continue button’, we link the on click event back to our end game manager. And add one last function to finish the process.
public void EndGame()
{
GameSceneManager.Instance.LoadLevel(GameScene.TheIsland);
Time.timeScale = 1;
}
We have custom level loading logic that involves a simple ‘loading scene’ and asynchronously loading the real level in the background. That might be covered in it’s own post at a later point, but you can do anything you want here. You can reload the same level your on with this.
Application.LoadLevel(Application.loadedLevel);
Or anything else you need. Just remember to set the time scale back to one. And you are good.
Also, if you are not comfortable with touching the time scale directly all the time, you can always create a class to manage the time scale for you and give yourself public methods like “Pause Time” “Resume Time” “Speed Up Time” “Slow Down Time” ect.