In today’s class, we discussed strategies for audio, and covered the Singleton pattern – a very powerful tool (but also a potentially tricky/dangerous one) for organizing our game and exposing our “manager” scripts to the objects in our scene. Tomorrow we will create the Game Manager which will handle our game state and flow, and on Friday we will spice up our visuals and learn about the “build” process.
Part 1: Audio Strategy
When we are making our game, there are a number of decisions that will center around how we want to treat the sounds in our game. When should they be playing in our game? What should be emitting the sound? Do I need to have control over the sound, such as starting and stopping playback, or adjusting the speed or pitch? Are my audio clips short or long? How often can I expect to hear each sound? These questions will inform our decisions as to which objects should emit what sounds, and how much control we may need to exert over them through code.
For most of my games, my sounds tend to fall into one of four categories:
- Omnipresent Sounds – these are sounds that will generally play throughout an experience, such as background music, or ambient noise. The sound is continuous, long, and probably loops. It tends to remain at a steady volume, regardless of position. For these types, I prefer to use an empty object that contains only an AudioSource component dedicated to playing the one sound, with the loop property selected. These may be set to play on awake or respond to a Play( ) command, depending on the start/stop conditions.
- Object Sounds – these sounds have an obvious point of origin. We perceive them as coming from some object in our scene – perhaps a character, perhaps an inanimate object. These may be lines of dialog, footsteps, music coming from a radio, a cat meowing, or a twinkling sound to indicate a point of interest. We expect these sounds to come from “somewhere” as opposed to “everywhere” so often we will want them to have a spatial quality or directionality. These are the most likely to be controlled by some behavior or react to some condition. In this case, I expect these to be generated from an AudioSource component that is attached to a GameObject or the child of an object in the scene, and managed some script, likely on the object itself. These sources will often have the 3D sound at least partially implemented, and will likely rely on the Play( ) command.
- Prefab Sounds – I see these as “object sounds on autopilot”. There is no logic expected to control the sound, they simply play when instantiated, and may continue for the duration of the instance object. These are best used for environmental sound effects that are a little longer or loop (such as a motor running, or a crackling fire), or a larger sound effect (such as a door opening, or longer explosion). Generally this involves a prefab object with an AudioSource with a preset AudioClip that will Play On Awake and may or may not Loop. This can be 2D or 3D sound depending on if your noise needs a point of origin. These will play through until the object is destroyed, or the clip is finished (if not looping).
- One-Shot Sounds – these are useful for quick sounds, especially repeating sounds, or sound that may overlap. PlayOneShot( ) is a play-and-forget function… once it begins you cannot stop it until it has played through. Also, this does not loop. It must be applied to an audio source, so it will respect the settings of that objects component, such as the spatial quality (and thus position of the object). This type is best for quick sound effects, especially those that may overlap from the same source. Footsteps, gunshots, UI noises, beeps, scoring noises, quick audio effects that don’t need to be controlled or shut off.
To start, we will create two simple “prefab sounds” – sound effects that will play for the Player Bullet and the Enemy Bomb prefabs. In this case the implementation is simple. I open my prefab to edit it, and attach the sound to the object either by dragging the clip onto the game object itself, or by adding an AudioSource component and setting the AudioClip to the appropriate source. I chose a short “pew” noise for my bullet, and a longer descending whistle noise for my enemy bomb. Both components were set to Play On Awake, so the audio plays automatically as soon as the element appears, and stops the moment the element is destroyed. (This is particularly helpful for the Bomb, which may collide with something before the clip has finished playing, hence the reason we use this implementation rather than a “one-shot” style sound.
Next, I’m going to add some ambient space noises, by creating an empty game object that will server as our emitter. I will add an AudioSource component and set my Audio Clip to be the appropriate sound, and make sure to select “Loop” so that it keeps playing.
These sources will now play when they are part of the scene, either by being placed there in the editor, or instantiated by code. Next we will look at defining a script to handle more of our sound generation.
Part 2: Sound Manager (Singleton)
Back when we discussed our “train station” metaphor, I mentioned a special method for assigning and calling a single instance of an object, known as the “Singleton” pattern. This is a way of declaring a class so that there is only ever one single instance of it. In other situations, the Singleton instantiates itself. Our code, however, is living inside the confines of our game engine, so we will still need to attach it to an object that exists in our scene, rather than relying on self-instantiation.
Think of the Singleton as being similar to the Presidency. At any given time, there is only one President. A new President may come along and replace the old, and when they do the former ceases to be the President. The responsibilities of the office only ever point to one person at a time… the current President.
So the Singleton pattern is a programming method by which we can define a particular instance of a class within the class declaration itself, so that we never have to worry about “finding” or establishing a relationship or link… we simply call the only instance by calling a property in the class that holds the instance itself.
Sounds confusing, and it kind of is, but just roll with it and you’ll see how this works.
First, we need to create something to generate the sounds. I first create an empty game object by going to Create > Create Empty, and naming it “SoundManager”. I then add a script to this, which I also name “SoundManager”. (These don’t have to share the same name, this is just a personal preference as I only expect to have one of these). I also add an AudioSource component to the SoundManager object… this is what will emit our PlayOneShot( ) sounds.
In our Sound Manager script, I create a variable “audio” to hold the reference to the AudioSource component, and I assign it in the Start( ) command with GetComponent<AudioSource>( ).
Now we need to make our SoundManager into a singleton. This way we can adjust sounds or modify them or turn them off as needed. If we instantiate our sounds as children of the Sound Manager, we only need to look internally to make adjustments, rather than performing costly “find” processes.
The first step to declare a singleton is to define a public static version of the class within itself, like so:
public class SoundManager: MonoBehaviour { public static SoundManager S; ...
Weird, right? We just set up a SoundManager type variable inside of our SoundManager type script?
Weirder still, watch what we do next:
private void Awake() { S = this; // Singleton Definition }
As our object wakes up, it declares itself to be this value “S”.
What is “public static” anyways? The “public” designation is pretty easy – it is a value that can be accessed from outside of the class. But the “static” designation means that all instances of the class share the same value for that variable. So in theory, you can create as many SoundManagers as you want, but if you access the “S” variable, you will always get a reference to the same object, the last one that woke up and set itself to “this”.
So why is this useful? Because now we no longer have to find our SoundManager. We can simply get directly to the active instance of our SoundManagerscript by writing SoundManager.S.{{whatever public variable or method}}
As a demonstration of this, we create a public function named MakeEnemyExplosionSound( ) which will play when an enemy is destroyed. The declaration is simple:
private void Start() { audio = GetComponent<AudioSource>(); } public void MakeEnemyExplosionSound() { audio.PlayOneShot(explosionClip); }
Now we can add a command in our Enemy.cs script that calls this particular function to generate the sound by writing:
SoundManager.S.MakeEnemySound();
It all simply works.
NOTE – Often times, the variable name “S” is used as shorthand for Singleton, but you can choose whatever name you want. You could call your SoundManager singleton “Steve”, so our script made calls to SoundManager.Steve.MakeExplosionSounds( ) instead. “S” is just a commonly used shorthand because Singleton starts with S.
Next we want to make some bomb and bullet sounds. These are simple, quick sounds, and should sound the same way every time. To accommodate this, I simply add the sounds to their respective Prefab objects, and in the AudioSource component I make sure that Play On Awake is selected. Now when they appear, they automatically make a noise.
Finally, it is time for some background music. I have a nice looping sci-fi track which I want to continue to play uninterrupted, until the player explodes, at which point I want it to stop. Also, I would like this sound to have adjustable volume so that I can make sure it does not overpower the sound effects of my game. Since I want this extra control, I am going to create a dedicated object for background music.
I create another empty object, and make this a child of the SoundManager. I add the AudioSource component, and for now, I keep Play On Awake selected. I also select Looping, so that this file will play continuously, going back to the beginning of the clip once it has finished. I also lower the Volume setting to keep this balanced in the background.
Now in my SoundManager.cs script, I add a new public AudioSource variable for background music. By declaring this AudioSource instead of GameObject, I can directly associate the component, without having to run GetComponent<> (which again, has a performance cost to it). Now I can create functions in the SoundManager that will Play( ) and Stop( ) the ambient sound, giving us an additional amount of control.
SoundManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class SoundManager : MonoBehaviour { // declare a singleton public static SoundManager S; // declare a variable for audio sources private AudioSource audio; // the manager's audio source [SerializeField] private AudioSource backgroundMusic; // the background music object's audio source // audio clips [SerializeField] private AudioClip explosionClip; [SerializeField] private AudioClip playerExplosionClip; private void Awake() { S = this; } // Start is called before the first frame update void Start() { audio = GetComponent<AudioSource>(); } public void MakeEnemyExplosionSound() { audio.PlayOneShot(explosionClip); } public void PlayerExplosion() { // make the explosion noise audio.PlayOneShot(playerExplosionClip); // stop the music StopTheMusic(); } public void StopTheMusic() { // stop playing the background music backgroundMusic.Stop(); } }
Enemy.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class Enemy : MonoBehaviour { public GameObject enemyBombPrefab; private void Update() { if (Input.GetKeyDown(KeyCode.B)) { DropABomb(); } } private void OnCollisionEnter(Collision collision) { if (collision.transform.tag == "PlayerBullet") { // make the explosion noise SoundManager.S.MakeEnemyExplosionSound(); // destroy myself Destroy(this.gameObject); // destroy the thing that hit me Destroy(collision.gameObject); } } public void DropABomb() { // make the bomb Instantiate(enemyBombPrefab, transform.position + Vector3.down, Quaternion.identity); } }
Player.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class Player : MonoBehaviour { public float speed; public float MAX_OFFSET; public GameObject bulletPrefab; // Update is called once per frame void Update() { // move the player object Vector3 currentPosition = transform.position; currentPosition.x = currentPosition.x + (Input.GetAxisRaw("Horizontal") * speed * Time.deltaTime); // clamp our value currentPosition.x = Mathf.Clamp(currentPosition.x, -MAX_OFFSET, MAX_OFFSET); // return the final position transform.position = currentPosition; if (Input.GetKeyDown(KeyCode.Space)) { FireBullet(); } } private void FireBullet() { Instantiate(bulletPrefab, (transform.position + Vector3.up), Quaternion.identity); } private void OnCollisionEnter(Collision collision) { if (collision.transform.tag == "EnemyBomb") { // make the player explode // make the explosion noise SoundManager.S.PlayerExplosion(); } } }
MotherShip.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class MotherShip : MonoBehaviour { public int stepsToSide; public float sideStepUnits; public float downStepUnits; public float timeBetweenSteps; public float timeBetweenBombs; // Start is called before the first frame update void Start() { StartCoroutine(MoveMother()); StartCoroutine(SendABomb()); } // Update is called once per frame void Update() { } public IEnumerator MoveMother() { // define the side step vector Vector3 moveVector = Vector3.right * sideStepUnits; // repeat the movement while this object has children while (transform.childCount > 0) { // move to the side (sequence) for (int i = 0; i < stepsToSide; i++) { // move to the side transform.position = transform.position + moveVector; // wait for the next move yield return new WaitForSeconds(timeBetweenSteps); } // now move down transform.position += (Vector3.down * downStepUnits); yield return new WaitForSeconds(timeBetweenSteps); // flip the direction moveVector *= -1.0f; } SoundManager.S.StopTheMusic(); Debug.Log("End of MoveMother script reached"); } public IEnumerator SendABomb() { // set the running flag bool isRunning = true; while(isRunning) { // see how many child objects exist int enemyCount = transform.childCount; // if there are children if (enemyCount > 0) { // pick one at random int enemyIndex = Random.Range(0, enemyCount); // get the chosen object Transform thisEnemy = transform.GetChild(enemyIndex); // make sure this has the Enemy component Enemy enemyScript = thisEnemy.GetComponent<Enemy>(); if (enemyScript) { // drop a bomb enemyScript.DropABomb(); } } else { // there are no children isRunning = false; } // wait for that time yield return new WaitForSeconds(timeBetweenBombs); } } }