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:
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:
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:
It is easy to play a piece of music with Pygame. Firstly, load the music file using the pygame.mixer.music.load()
function:
pygame.mixer.music.load("music.mp3")
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:
pygame.mixer.music.play(loops=-1)
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:
pygame.mixer.music.load("17718_1462204250.mp3")
pygame.mixer.music.play(loops=-1)
Copy and paste these lines at the beginning of a Pygame program, update the music file, run, and listen!
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:
fireSound.play()
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 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.
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 Layer
class:
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 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!
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 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.
There are two kinds of events: the factual ones and the request ones. The factual ones indicate that we changed something, like worldSizeChanged()
.
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 showMenu()
method.
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 self.ui.quitGame()
by self.notifyQuitRequested()
and self.ui.showMenu()
by self.notifyShowMenuRequested()
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!