The Observer Pattern
The Observer Pattern is a software design pattern that allows you to create modular game logic that is executed when an event in the game is triggered. It typically works by allowing observers, in this case, other scripts, to subscribe one or more of their own functions to a subject’s event.
Refactor PlayerMovement
We are now ready to utilise the new input system to control Mario's movement. Let's refactor a few methods in PlayerMovement.cs
. Firstly, take out the code at Update()
that flips Mario's sprite to face left or right into a new method that takes in an integer. If that integer is -1
, it means that Mario should face left and we flip the sprite if he's initially facing right, and vice versa. Then, we also refactor FixedUpdate()
to separate the the logic of moving Mario and making Mario Jump.
- FlipMarioSprite()
- MoveCheck(int value)
- Jump()
- JumpHold()
void Update()
{
marioAnimator.SetFloat("xSpeed", Mathf.Abs(marioBody.velocity.x));
}
void FlipMarioSprite(int value)
{
if (value == -1 && faceRightState)
{
faceRightState = false;
marioSprite.flipX = true;
if (marioBody.velocity.x > 0.05f)
marioAnimator.SetTrigger("onSkid");
}
else if (value == 1 && !faceRightState)
{
faceRightState = true;
marioSprite.flipX = false;
if (marioBody.velocity.x < -0.05f)
marioAnimator.SetTrigger("onSkid");
}
}
private bool moving = false;
void FixedUpdate()
{
if (alive && moving)
{
Move(faceRightState == true ? 1 : -1);
}
}
void Move(int value)
{
Vector2 movement = new Vector2(value, 0);
// check if it doesn't go beyond maxSpeed
if (marioBody.velocity.magnitude < maxSpeed)
marioBody.AddForce(movement * speed);
}
public void MoveCheck(int value)
{
if (value == 0)
{
moving = false;
}
else
{
FlipMarioSprite(value);
moving = true;
Move(value);
}
}
private bool jumpedState = false;
public void Jump()
{
if (alive && onGroundState)
{
// jump
marioBody.AddForce(Vector2.up * upSpeed, ForceMode2D.Impulse);
onGroundState = false;
jumpedState = true;
// update animator state
marioAnimator.SetBool("onGround", onGroundState);
}
}
public void JumpHold()
{
if (alive && jumpedState)
{
// jump higher
marioBody.AddForce(Vector2.up * upSpeed * 30, ForceMode2D.Force);
jumpedState = false;
}
}
Notice how MoveCheck
, Jump
and JumpHold
are declared as public
? That's on purpose. This is because we want to register them with UnityEvent
later on.
The idea is as follows:
- When
move
action is performed, we want to callMove(value)
wherevalue
is 1 or -1 depending on whether Mario is moving to the right of to the left. - When
move
action is canceled, we want to callMove(0)
We could do this by referencing Mario's PlayerMovement script and call the methods manually, or by using Events. In particular, we are going to implement the observer pattern using UnityEvent.
When the event is triggered by the owner or whoever Invoke()
it, the observers’ functions are called in response. So for instance, when move
action is cancelled, it will invoke a moveCheck
Event. Any methods subcribed to moveCheck
will be called in sequence.
UnityEvent
Unity events are a way to hook up function calls between GameObjects in the editor and serialize those calls. They are designed to be populated by the developer at design-time. Create a new script (if you haven't already) called ActionManager.cs
and attach it to Mario:
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.InputSystem;
public class ActionManager : MonoBehaviour
{
public UnityEvent jump;
public UnityEvent jumpHold;
public UnityEvent<int> moveCheck;
public void OnJumpHoldAction(InputAction.CallbackContext context)
{
if (context.started)
Debug.Log("JumpHold was started");
else if (context.performed)
{
Debug.Log("JumpHold was performed");
Debug.Log(context.duration);
jumpHold.Invoke();
}
else if (context.canceled)
Debug.Log("JumpHold was cancelled");
}
// called twice, when pressed and unpressed
public void OnJumpAction(InputAction.CallbackContext context)
{
if (context.started)
Debug.Log("Jump was started");
else if (context.performed)
{
jump.Invoke();
Debug.Log("Jump was performed");
}
else if (context.canceled)
Debug.Log("Jump was cancelled");
}
// called twice, when pressed and unpressed
public void OnMoveAction(InputAction.CallbackContext context)
{
// Debug.Log("OnMoveAction callback invoked");
if (context.started)
{
Debug.Log("move started");
int faceRight = context.ReadValue<float>() > 0 ? 1 : -1;
moveCheck.Invoke(faceRight);
}
if (context.canceled)
{
Debug.Log("move stopped");
moveCheck.Invoke(0);
}
}
}
We assume you used Invoke Unity Events behavior in Player Input
component as shown:
Then, serialize the three events: Jump, JumpHold, and MoveCheck at the inspector to call the relevant functions in PlayerMovement.cs
script:
Don't forget to use Dynamic parameter:
If we did not set MoveCheck
to be public, then we would not be able to select that function from the inspector. What we did above is declare three events that will be invoked whenever there's interaction. for instance, we call jump.Invoke()
under OnJumpAction
callback, but only when context.performed
is true
. This will in turn call the method Jump()
defined in PlayerMovement.cs
.
The method MoveCheck(int value)
takes in one int
parameter, and so we need to use UnityEvent with generic type, and declare it as public UnityEvent<int> moveCheck
.
Save and test that Mario can still move, skid, etc, jump, along with higher jump when you hold the spacebar.
Deep Dive: UnityEvent vs UnityAction
- UnityEvent
- UnityAction
class Game {
public static UnityEvent OnLogin = new UnityEvent();
}
class LoginForm {
Awake() {
Login();
}
private void Login() {
Game.OnLogin.Invoke();
}
}
class Player {
Awake() {
Game.OnLogin.AddListener(OnLoggedIn);
}
private void OnLoggedIn() {
isLogged = true;
}
}
class Game {
public static UnityAction OnLogin = new delegate {};
}
class LoginForm {
Awake() {
Login();
}
private void Login() {
Game.OnLogin();
}
}
class Player {
Awake() {
Game.OnLogin += OnLoggedIn;
}
private void OnLoggedIn() {
isLogged = true;
}
}
The difference between the two is that UnityEvent
show up on inspector and you can serialize OnLogin
there, whereas UnityAction
must be used solely from script. It comes down to style choice.
Detect Mouse Click
Although mouse click is not necessary in Super Mario Bros, let's try to capture the position of a mouse click on the Game screen for the sake of other games that you might create. Firstly, let's rename our control scheme into MarioActions because we no longer just use the keyboard (it's good to give naming that makes sense). Then, edit the control scheme and add the Mouse device.
If you don't add Mouse into the control scheme, you will not be able to detect mouse clicks!
Then as shown in the video above, add an action called click
that detects Left Button Mouse press. This alone however is insufficient to capture the "location" of the mouse. We want it to also report the location of the mouse click, and for this we need to create a Binding with One Modifier. A modifier is a condition that has to be held for binding to come through. Here we set the action to pass a value of control type Vector2, the Modifier as the Left Button mouse click, and the Binding as Mouse position.
Please read the documentation about bindings and modifiers here.
Now we can define two more callbacks in ActionManager.cs
:
public void OnClickAction(InputAction.CallbackContext context)
{
if (context.started)
Debug.Log("mouse click started");
else if (context.performed)
{
Debug.Log("mouse click performed");
}
else if (context.canceled)
Debug.Log("mouse click cancelled");
}
public void OnPointAction(InputAction.CallbackContext context)
{
if (context.performed)
{
Vector2 point = context.ReadValue<Vector2>();
Debug.Log($"Point detected: {point}");
}
}
Set the callbacks inside Player Input component, and test the mouse clicks in the console:
Why ActionManager.cs
?
Can we implement the input system callbacks directly at PlayerMovement.cs
? Yes, sure we can. It comes down to preference and principle: do you prefer separating the scripts between managing your actions state (deciding what to do depending on the context
state) and implementing the game logic (deciding how to jump)? There's no right answer to any of this.
Be Careful when Changing Method Name
If you happen to refactor your code and change the method name, e.g: onJumpHoldAction
into OnJumpHoldAction
, whatever you have set on the inspector will not change with it. It will be written as missing:
You have to fix it too in the inspector. This is very tedious, but so is creating everything via script. It's a give and take.
Delegates
We used UnityEvent
above as some kind of function container that we can Invoke()
and then it will call all functions subcribed to it in order. We don't really see it in the example above because we only have one function subscribed to it as defined in the Inspector. Let's dive deeper into how it works.
A delegate is a reference pointer to a method. It allows us to treat method as a variable and pass method as a variable for a callback. When a delegate gets called, it notifies all methods that reference the delegate.
The basic idea behind them is exactly the same as a subscription magazine. Anyone can subscribe to the service and they will receive the update at the right time automatically.
You can declare a delegate with the delegate keyword and specifies its signature (return type and parameters):
public delegate returnType MethodName (paramType1 paramName1, paramType2 paramName2, ...);
For example, something like this:
public delegate void SimpleGameEvent();
You can declare a delegate without placing it within a class in C#. Delegates are a type that can be declared at the namespace level, which means they can exist outside of any class or structure. This makes it accessible to any class or code within the namespace.
C# Event
To allow other scripts to subscribe to this delegate, we need to create an instance of that delegate, using the event
keyword:
public static event SimpleGameEvent IncreaseScore;
We can also use the delegate directly using its name without the event
keyword:
public static SimpleGameEvent IncreaseScore;
Without the event
keyword, IncreaseScore
can be cast by anyone (unless it is not public, but that will mean that not every other script can subscribe to it). If we want only the owner of the delegate to cast, then the event keyword is used.
Since the event is declared as static
, that means other scripts can subscribe to it via the Classname. Whether you should make an event static or not depends on your design and how you intend to use it.
- If you want a single event that is shared across all instances or if you need to raise the event without having an instance of the class, make it
static
. - If you want each instance to have its own event handling, make it non-static.
DeepDive: C# Events
You can try and experiment with C# events with the following 3 scripts:
- SomeEventManager.cs
- SomeEventManagerNonStatic
- TestEventInPlayground.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// declare delegate at namespace level
public delegate void SomeGameEvent(int score);
public class SomeEventManager
{
// with event keyword, can only be invoked within this class
public static event SomeGameEvent SampleEventFoo;
// without event keyword, can be invoked by anyone else
public static SomeGameEvent SampleEventBar;
public static void LaunchEventFooBy(int points)
{
// Raise the static event
SampleEventFoo?.Invoke(points);
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SomeEventManagerNonStatic : MonoBehaviour
{
// static event
public static event SomeGameEvent SampleEventBaz;
// non static event
public event SomeGameEvent SampleEventQux;
// these methods needs an instance of PlaygroundEventManager
public void LaunchSampleEventBaz(int points)
{
// Raise the static event
SampleEventBaz?.Invoke(points);
}
public void LaunchSampleEventQux(int points)
{
// Raise the instance event
SampleEventQux?.Invoke(points);
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TestEventInPlayground : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
// subscribe without having the instance of the class
SomeEventManager.SampleEventFoo += TestEventFoo;
SomeEventManager.SampleEventBar += TestEventBar;
SomeEventManagerNonStatic.SampleEventBaz += TestEventBaz;
// subscribe by finding instance of the class
FindObjectOfType<SomeEventManagerNonStatic>().SampleEventQux += TestEventQux;
}
public void TestEventFoo(int score)
{
Debug.Log($"TestEventFoo is called with parameter value {score}");
}
public void TestEventBar(int score)
{
Debug.Log($"TestEventBar is called with parameter value {score}");
}
public void TestEventBaz(int score)
{
Debug.Log($"TestEventBaz is called with parameter value {score}");
}
public void TestEventQux(int score)
{
Debug.Log($"TestEventQux is called with parameter value {score}");
}
public void Press()
{
// invoke via PlaygroundEventManager static method
SomeEventManager.LaunchEventFooBy(1);
// invoke event directly
SomeEventManager.SampleEventBar.Invoke(2);
// invoke from instance
FindObjectOfType<SomeEventManagerNonStatic>().LaunchSampleEventBaz(3);
FindObjectOfType<SomeEventManagerNonStatic>().LaunchSampleEventQux(4);
}
}
Now attach SomeEventManagerNonStatic
and TestEventInPlayground
to some test GameObject, and then create a button to call Press()
: you will find four Debug messages printed out at the Console:
SampleEventFoo
andSampleEventBar
are invoked without the need to make an instance ofSomeEventManager
class.SampleEventBaz
, despite being declared asstatic
, could not be invoked without an instance ofSomeEventManagerStatic
because of theevent
keyword when declaring it.- We can Invoke static event
SampleEventBaz
in non-static method SampleEventQux
will not exist without an instance ofSomeEventManagerNonStatic
class.
In the code above, the SomeGameEvent
delegate is declared at the namespace level, making it accessible to any class or code within that namespace. This allows you to use the SomeGameEvent
delegate in various classes or parts of your application without the need to encapsulate it within a specific class.
In the example above we simply utilise SomeGameEvent
in another class: SomeEventManagerNonStatic
.
UnityEvent
We will mainly utilise UnityEvent
instead of C# Event and delegates because the former allows us to conveniently set it up via the inspector and that it covers basic signatures that we need (return type of void
and accept generic parameters, up to four).
Increase Game Score with Coin
When Mario touches a box or brick with Coin, we are supposed to increase the game score. Right now the score
state is stored in JumpOverGoomba
. PlayerMovement
has to reference this script in order to change its score
during restart:
public void ResetGame()
{
// ... other instructions
// reset Goomba
foreach (Transform eachChild in enemies.transform)
{
eachChild.localPosition = eachChild.GetComponent<EnemyMovement>().startPosition;
}
// reset score
jumpOverGoomba.score = 0;
// reset animation
marioAnimator.SetTrigger("gameRestart");
alive = true;
// reset camera position
gameCamera.position = new Vector3(0, 0, -10);
}
ScoreText
is also modified by three scripts separately: GameManager
, JumpOverGoomba
, and PlayerMovement
. Here's a graph that illustrates referencing between scripts/GameObjects:
It's pretty messy right now:
- ScoreText is referenced by three different scripts
- PlayerMovement and GameManager controls each other
- GameManager does not "manage" the game: score is stored inside JumpOverGoomba
Let's fix it to something neater as follows:
Major Refactoring using Events
We need to create four different events: GameOver
, GameStart
, GameRestart
, and ScoreChange
in GameManager.cs
and let other scripts subscribe to it and update themselves accordingly. Create two new scripts: HUDManager.cs
and EnemyManager.cs
which will contain callbacks to subscribe to the events above:
Do not blindly copy paste the content of the methods below. Your actual implementation might vary, for instance you might not have the GameOverPanel
in your implementation if you did not choose to do it for Checkoff 1. These files are for your reference only.
- HUDManager.cs
- EnemyManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
public class HUDManager : MonoBehaviour
{
private Vector3[] scoreTextPosition = {
new Vector3(-747, 473, 0),
new Vector3(0, 0, 0)
};
private Vector3[] restartButtonPosition = {
new Vector3(844, 455, 0),
new Vector3(0, -150, 0)
};
public GameObject scoreText;
public Transform restartButton;
public GameObject gameOverPanel;
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
public void GameStart()
{
// hide gameover panel
gameOverPanel.SetActive(false);
scoreText.transform.localPosition = scoreTextPosition[0];
restartButton.localPosition = restartButtonPosition[0];
}
public void SetScore(int score)
{
scoreText.GetComponent<TextMeshProUGUI>().text = "Score: " + score.ToString();
}
public void GameOver()
{
gameOverPanel.SetActive(true);
scoreText.transform.localPosition = scoreTextPosition[1];
restartButton.localPosition = restartButtonPosition[1];
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class EnemyManager : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
public void GameRestart()
{
foreach (Transform child in transform)
{
child.GetComponent<EnemyMovementWeek3>().GameRestart();
}
}
}
Then modify these existing files such that we no longer have to refer to scripts in inspector and we don't store score
in JumpOverGoomba.cs
:
- GameManager.cs
- PlayerMovement.cs
- JumpOverGoomba.cs
- EnemyMovement.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
public class GameManagerWeek3 : MonoBehaviour
{
// events
public UnityEvent gameStart;
public UnityEvent gameRestart;
public UnityEvent<int> scoreChange;
public UnityEvent gameOver;
private int score = 0;
void Start()
{
gameStart.Invoke();
Time.timeScale = 1.0f;
}
// Update is called once per frame
void Update()
{
}
public void GameRestart()
{
// reset score
score = 0;
SetScore(score);
gameRestart.Invoke();
Time.timeScale = 1.0f;
}
public void IncreaseScore(int increment)
{
score += increment;
SetScore(score);
}
public void SetScore(int score)
{
scoreChange.Invoke(score);
}
public void GameOver()
{
Time.timeScale = 0.0f;
gameOver.Invoke();
}
}
public void GameRestart()
{
// reset position
marioBody.transform.position = new Vector3(-5.33f, -4.69f, 0.0f);
// reset sprite direction
faceRightState = true;
marioSprite.flipX = false;
// reset animation
marioAnimator.SetTrigger("gameRestart");
alive = true;
// reset camera position
gameCamera.position = new Vector3(0, 0, -10);
}
GameManager gameManager;
void Start(){
gameManager = GameObject.FindGameObjectWithTag("Manager").GetComponent<GameManager>();
}
void FixedUpdate()
{
// when jumping, and Goomba is near Mario and we haven't registered our score
if (!onGroundState && countScoreState)
{
if (Mathf.Abs(transform.position.x - enemyLocation.position.x) < 0.5f)
{
countScoreState = false;
gameManager.IncreaseScore(1); //
}
}
}
public void GameRestart()
{
transform.localPosition = startPosition;
originalX = transform.position.x;
moveRight = -1;
ComputeVelocity();
}
There are many things to do, but the big idea is that we need to:
- Remove storing score at
JumpOverGoomba.cs
- Remove script references in inspector except to
GameManager.cs
(see video below)
Your actual implementation may vary a little, such as the game restart and gameover scene that is part of Checkoff 1.
Animation Event Tool
Now that we have refactored everything to utilise Events as much as possible and removing direct references to scripts, we can register a callback via AnimationEvent when a coin spawn is triggered. Specifically, we want to call the IncreaseScore(1)
method inside GameManager instance.
Recall that AnimationEvent will only list out public methods with signature: return type void
and zero argument only from scripts attached to that GameObject where the Animator component is? That means it will not detect IncreaseScore method since that method is within GameManager and not Coin.
To help us, we shall create a helper script. We name it AnimationEventIntTool.cs
:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
public class AnimationEventIntTool : MonoBehaviour
{
public int parameter;
public UnityEvent<int> useInt;
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
}
public void TriggerIntEvent()
{
useInt.Invoke(parameter); // safe to invoke even without callbacks
}
}
Usage steps:
- Attach the
AnimationEventIntTool.cs
to all coins. - Then for all coins, modify its animation clip to trigger events at the end of it.
- Link up GameManager instance of this Scene to get the
IncreaseScore(int score)
callback at the inspector
In our demo below, we have two events (but you might just have this one, it's fine). Make sure you set it to call all your events from all scripts attached to the Coin.
In the beginning, we set GameManager's IncreaseScore into all Coin script, but we seem to have to do it again at 1:09. This is because we apply the changes to ALL question-box-coin prefab at 00:42.
GameManager IncreaseScore is a Scene instance, meaning that it does not persist. A prefab cannot refer to a script instance from another GameObjects that is NOT part of the prefab, because it wouldn't know if that instance will exist in the scene that it's currently spawned at (yes, we can instantiate prefabs at any scene at runtime). That's why the blue line indicator besides all the prefab in the Hierarchy does not disappear even after we apply the changes to all prefabs:
We also need to manually set ALL coins to call IncreaseScore from this scene's GameManager instance, as you see in the beginning of the video or at 1:09.
Summary
We have seen how convenient it is to set callbacks via the inspector. However, if our callbacks come from another GameObject instance that's not part of the prefab's, then we would have to do that each time we instantiate a prefab.
Setting things up from the inspector (as opposed to from the script) can be a double-edged sword. On one hand: it is convenient and visually affirming, while on the other hand: it is easy to miss a few things or "forgot" to set it up.