In this post, I add music and sounds to our tank game and make use of the Observer pattern to implement it efficiently.
This post is part of the Discover Python and Patterns series
The objectives are the following:
- Play a piece of music when the game starts;
- Play a piece of music when a level is loaded;
- Play a piece of music when the player wins;
- Play a piece of music when the player loses;
- Play a sound when a unit fires;
- Play a sound when a unit is destroyed.
The pieces of music from https://www.freesfx.co.uk, and are all royalty-free (https://www.freesfx.co.uk/Music.aspx). I keep the original file names to better credit the authors:
- “17718_1462204250.mp3”: Menu music, called “Gang War”;
- “17687_1462199612.mp3”: Level music, called “Military Madness”;
- “17382_1461858477.mp3”: Victory music, called “Opening Day”;
- “17675_1462199580.mp3”: Game over music, called “Deadly Talk”.
The sounds come from https://freesound.org, and their license is Attribution-NonCommercial 3.0 Unported (https://creativecommons.org/licenses/by-nc/3.0/). I also keep the original file names to better credit the authors:
- “170274__knova__rifle-fire-synthetic.wav”: Bullet fire sound, created by knova (https://freesound.org/people/knova/);
- “110115__ryansnook__small-explosion.wav”: Explosion sound, created by ryansnook (https://freesound.org/people/ryansnook/).
Music with Pygame
It is easy to play a piece of music with Pygame. Firstly, load the music file using the
Note that it stops any music already playing. Also, note that you can only have one piece at a time.
Once the music file is loaded, you can start it using the
pygame.mixer.music.play() function. This function has several optional arguments. In our case, we set
loops to -1 to repeat indefinitely:
You can find more details here: https://www.pygame.org/docs/ref/music.html.
In the constructor of the
UserInterface class, we add to two following lines to get a piece of music playing at the beginning of the game:
Copy and paste these lines at the beginning of a Pygame program, update the music file, run, and listen!
Play a sound with Pygame
On the contrary to the music, you can load and store several sounds in Python variables using the
pygame.mixer.Sound class constructor. For instance:
fireSound = pygame.mixer.Sound("fire.wav") explosionSound = pygame.mixer.Sound("explosion.wav")
Then, use the
play() method of the
pygame.mixer.Sound class to play the sound:
You can play several sounds at the same time. There are many other methods, like the
set_volume() method that sets the volume of the sound. You can find more details here: https://www.pygame.org/docs/ref/mixer.html#pygame.mixer.Sound.
A naive approach
A naive way to trigger music changes or sounds playing is to repeat the two previous lines when something is modified. For instance, at the end of the
update() method of the
PlayGameMode class, when we evaluate if the game is over, we could immediately change the music:
if self.playerUnit.status != "alive": self.gameOver = True pygame.mixer.music.load("17675_1462199580.mp3") pygame.mixer.music.play(loops=-1) else: oneEnemyStillLives = False for unit in self.gameState.units: if unit == self.playerUnit: continue if unit.status == "alive": oneEnemyStillLives = True break if not oneEnemyStillLives: self.gameOver = True pygame.mixer.music.load("17382_1461858477.mp3") pygame.mixer.music.play(loops=-1)
It works fine, but we introduce a dependency between the game updates and the user interface. As you should know, in software design, we don’t like dependencies and try to avoid them as much as possible.
Imagine that we want to create a multiplayer version of our game. In this case, there is a game server that runs the updates. This server has no screen and no user interface. With the naive approach, if we do nothing, the server plays music! Even if it works fine, it uses CPU and disk I/O unnecessarily.
We could patch the code to add a condition to play music, like
if self.playMusic: .... It is a small example, but in real cases, there are thousands of such triggers. We would lose a lot of time and risk to forget to patch some lines.
They are also many other examples like this one, and as usual, I invite you to trust the many developers that faced them. They finally created solutions like the one I present in the next section.
Sounds with the Observer pattern
The Observer pattern allows us to remove the dependency between modules while creating links between them. These links are dynamics, and the observed one does not have to worry about who observes it.
We already used it with the game state:
The game state can notify any observer (or listener) that a unit is destroyed. Currently, the
ExplosionLayer class observes the
GameState class, and when it receives this notification, it starts an explosion.
We can add a new
bulletFired() method to our observer scheme to handle bullet shots:
Then, in the
ShootCommand class, we call the
notifyBulletFired() method of the
GameState class to indicate that a bullet is fired.
At this point, anyone observing the game state can know when a unit is destroyed, and when a bullet is fired.
For the sound case, I propose to create a new
SoundLayer class, child of the
class SoundLayer(Layer): def __init__(self,fireFile,explosionFile): self.fireSound = pygame.mixer.Sound(fireFile) self.fireSound.set_volume(0.2) self.explosionSound = pygame.mixer.Sound(explosionFile) self.explosionSound.set_volume(0.2) def unitDestroyed(self,unit): self.explosionSound.play() def bulletFired(self,unit): self.fireSound.play() def render(self,surface): pass
Even if this layer does not render anything, we can also consider it as one of the blocks that create the multimedia user experience. If you don’t like that, you can create two branches, for instance, a
VisualLayer base class for visual layers, and an
AudioLayer base class for audio layers.
The implementation of this class is straightforward:
- It loads the sounds in the constructor (lines 3-6). Note that I choose to reduce the volume to put music in front. We could also add options in the menu to let the player set that;
- It plays the explosion sound when a unit is destroyed (line 9);
- It plays the fire sound when a bullet is fired (line 12).
It seems that Pygame handles well the playing of many sounds. If it was not the case, we could handle it easily in this class, for instance, using a delay or a maximum number of simultaneous sounds. It would be easy because we use the Observer pattern. Otherwise, we would have to carry these values (delay or maximum) in many places in the program! A true nightmare, trust me!
Observe the game state
If we add the sound layer to the list of layers in the constructor of the
PlayGameMode class, then it will observe the game state thanks to the lines right after:
self.layers = [ ArrayLayer(self.cellSize,"ground.png",self.gameState,self.gameState.ground,0), ArrayLayer(self.cellSize,"walls.png",self.gameState,self.gameState.walls), UnitsLayer(self.cellSize,"units.png",self.gameState,self.gameState.units), BulletsLayer(self.cellSize,"explosions.png",self.gameState,self.gameState.bullets), ExplosionsLayer(self.cellSize,"explosions.png"), SoundLayer("170274__knova__rifle-fire-synthetic.wav","110115__ryansnook__small-explosion.wav") ] # All layers listen to game state events for layer in self.layers: self.gameState.addObserver(layer)
Run the game with these changes, and the game now has sounds 🙂
Music with the Observer pattern
Music changes are on a higher level than the game updates. For instance, it can happen between game creation and destruction. As a result, I propose to apply the Observer pattern on the game modes:
There are many cases to handle and as many methods. Please don’t be afraid of the number of these methods. There is software that automatically creates these methods and their implementation. If you don’t want to use them or can’t afford them, you can implement processes of your own. It especially easy with Python because the standard library already contains a code parser and updater that works very fine for this kind of refactoring.
Factual and request events
There are two kinds of events: the factual ones and the request ones. The factual ones indicate that we changed something, like
The request ones are queries: maybe we didn’t change anything, but we would like something to happen, and we can’t do it ourselves. For instance, the
showMenuRequested() event means that we would like that the menu pops up, but we can’t do it. For instance, the game is running, and the player hits the escape key.
Since the game is running, it means that the
PlayGameMode is active and captures the key events. The player hits the escape key, meaning that (s)he wants to get the menu. The
PlayGameMode class must not handle that, because it involves a higher level in the user interface since it destroys and creates modes, including the play game mode. In the previous program, we already delegated this to the
UserInterface class thanks to its
Calling a method of the
UserInterface in the
PlayGameMode is creating a circular dependency. Instead, the
PlayGameMode calls the
notifyShowMenuRequested() method. If we let the
UserInterface listen to the
PlayGameMode, then it can show the menu. Since it is a request, there is no warranty that someone will fulfill it.
In many cases, the implementation is straightforward: we replace the call to a method of the
UserInterface by a notification. For instance, in the
processInput() method of the
MessageGameMode class, we replace
for event in pygame.event.get(): if event.type == pygame.QUIT: self.notifyQuitRequested() break elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE \ or event.key == pygame.K_SPACE \ or event.key == pygame.K_RETURN: self.notifyShowMenuRequested()
GameMode child classes no more have a
ui attribute that points to the
UserInterface class. We removed the dependency.
For the other cases, where something happens, we update the user interface and change the music when necessary. For instance, when the game is lost or won:
def gameWon(self): self.showMessage("Victory !") pygame.mixer.music.load("17382_1461858477.mp3") pygame.mixer.music.play(loops=-1) def gameLost(self): self.showMessage("GAME OVER") pygame.mixer.music.load("17675_1462199580.mp3") pygame.mixer.music.play(loops=-1)
In the next post, we’ll see how to package our game so that anyone can run it!