Python Asteroids on GitHub

Let’s put in some more sounds. I anticipate little trouble. Even here, we learn some interesting lessons.

I downloaded new copies of the .wav files from here, and moved them into the sounds folder in the project. Commit: add sounds to project

The next step is to set them up for play in the Sounds class. I’ll start them all at 0.5 volume, and then adjust as needed by my ears.

class Sounds:
    def init_sounds(self):
        self.add_sound("accelerate", "sounds/thrust.wav", 0.5)
        self.add_sound("bang_large", "sounds/bangLarge.wav", 0.5)
        self.add_sound("bang_medium", "sounds/bangMedium.wav", 0.5)
        self.add_sound("bang_small", "sounds/bangSmall.wav", 0.5)
        self.add_sound("beat1", "sounds/beat1.wav", 0.5)
        self.add_sound("beat2", "sounds/beat2.wav", 0.5)
        self.add_sound("extra_ship", "sounds/extraShip.wav", 0.5)
        self.add_sound("fire", "sounds/fire.wav", 0.5)
        self.add_sound("saucer_big", "sounds/saucerBig.wav", 0.5)
        self.add_sound("saucer_small", "sounds/saucerSmall.wav", 0.5)

Commit: define available sounds.

Now for the fun part. Let’s make a noise when the ship fires its missile.

There’s good news, and bad news. The right spot is here:

class Ship:
    def create_missile(self):
        player.play("fire", self._location)
        return Missile.from_ship(self.missile_start(), self.missile_velocity())

The bad news is that the fire sound (pew pew) runs long enough that if I fire rapidly, I don’t get a sound on each tap. That’s disconcerting. Recall that we do not start a sound if it’s already running:

    def play(self, name, location=None):
        if name in self.catalog:
            sound = self.catalog[name]
            count = sound.get_num_channels()
            if count == 0:
                chan = self.catalog[name].play()
                self.set_volume(chan, location)

We check how many channels the sound is already playing on, and if more than zero, we do not trigger it again. Let’s add a parameter to play, indicating how many channels are allowed.

    def play(self, name, location=None, allowed=1):
        if name in self.catalog:
            sound = self.catalog[name]
            count = sound.get_num_channels()
            if count < allowed:
                chan = self.catalog[name].play()
                self.set_volume(chan, location)

We’ll default to allow only one instance, but now:

    def create_missile(self):
        player.play("fire", self._location, 4)
        return Missile.from_ship(self.missile_start(), self.missile_velocity())

That works as advertised. And, of course, the sound is in stereo, louder from the left speaker when you’re on the left side of the screen, and so on.

Commit: ship makes firing sound when firing missile.

What would be good now? Let’s make the asteroids make a sound when exploding. We’ll put off doing that for the ship.

Asteroids can split or die, as shown here:

class Asteroid:
    def split_or_die(self, asteroids):
        if self not in asteroids:
            return # already dead
        asteroids.remove(self)
        if self.size > 0:
            a1 = Asteroid(self.size - 1, self.position)
            asteroids.append(a1)
            a2 = Asteroid(self.size - 1, self.position)
            asteroids.append(a2)

This seems easy enough:

    def split_or_die(self, asteroids):
        if self not in asteroids:
            return # already dead
        asteroids.remove(self)
        self.explode()
        if self.size > 0:
            a1 = Asteroid(self.size - 1, self.position)
            asteroids.append(a1)
            a2 = Asteroid(self.size - 1, self.position)
            asteroids.append(a2)

    def explode(self):
        sound = ["bang_small", "bang_medium", "bang_large"][self.size]
        player.play(sound, self._location, 3)

Perfect. This is so easy. Can I take any credit? Well, my code is nicely factored, so it’s easy to spot where to put the sounds. But it would be hard to write code so badly as to make it difficult. So, well, I’ll take the credit anyway.

I randomly chose 3 as the number of channels to allow for bangs. I think we may want to provide more than the standard 8, but we’ll see. I guess the ship should explode, and I’d like a bit of noise when the saucer gets his.

Commit: asteroids bang when destroyed.

class Saucer:
    def destroyed_by(self, _attacker, saucers, _fleets):
        player.play("bang_large", self._location, 2)
        player.play("bang_small", self._location, 2)
        if self in saucers: saucers.remove(self)

I thought I’d try playing two explosions. Might be interesting.

I don’t notice any real difference. And I really want a visual effect when the saucer dies. Make a Jira.

Aside
I typed in the trivial code above incorrectly, twice. First, I put in the count and not the location. That quickly failed in the game, once I was able to put the hit on the saucer. Then I put the location after the count. That also failed.

A language like Kotlin, with compile-time typing, would have told me that the call was incorrect. PyCharm, at least as I’m using it, did not. One tick for Kotlin, black X mark for Python. I still prefer Python, it fits me more comfortably. Probably on a larger project, I’d be wrong to prefer Python.

I think that we have an issue here. Rather than specify in the call how many channels a sound can use, we should specify it as part of the sound definition. Let’s commit this and then try that.

Commit: saucer triggers two explosion sounds on destruction.

Multi-Channel Sounds

We want our sound definitions to include how many simultaneous channels can be allowed to play the sound.

Well, I think we want that. Let’s consider. So far, we’ve wanted the thrust sound to be used on only one channel, and we want all the others to play freely. We have no reason to limit any of the others.

We could do something very general, such as specifying for each sound how many channels it can use. But we only have one case that’s different from the others. Let’s look at this from the viewpoint that there’s just one special case, acceleration.

class Sounds:
    def play(self, name, location=None, allowed=1):
        if name in self.catalog:
            sound = self.catalog[name]
            count = sound.get_num_channels()
            if count < allowed:
                chan = self.catalog[name].play()
                self.set_volume(chan, location)

What if we change it so that the final parameter is multi_channel and it defaults to True?

Let’s try change signature.

    def play(self, name, location=None, multi_channel=True):
        if name in self.catalog:
            sound = self.catalog[name]
            count = sound.get_num_channels()
            if multi_channel or count == 0:
                chan = self.catalog[name].play()
                self.set_volume(chan, location)

I change all the calls accordingly. Commit: change Sounds to default to multi-channel play for a sound. Ship acceleration allows only one channel.

The saucer is supposed to make a noise as it flies. That will want to use our new one-channel feature.

class Saucer:
    def tick(self, delta_time, fleet, fleets):
        player.play("saucer_big", self._location, False)
        saucer_missiles = fleets.saucer_missiles
        ships = fleets.ships
        self.fire_if_possible(delta_time, saucer_missiles, ships)
        self.check_zigzag(delta_time)
        self.move(delta_time, fleet)

Perfect, except that it’s too piercing, so I dial it down to volume 0.3.

Commit: saucer makes saucer noises.

What else? Ship explosion, I suppose.

class Ship:
    def explode(self, ship_fleet, fleets):
        player.play("bang_large", self._location)
        if self in ship_fleet: ship_fleet.remove(self)
        fleets.explosion_at(self.position)

That didn’t work, and the reason why is that explode is called when hyperspace fails, but not when you are shot down. The fix is this:

class Ship:
    def destroyed_by(self, attacker, ships, fleets):
        self.explode(ships, fleets)

That code used to duplicate the removal and explosion part. This is why we try always to remove duplication, class.

Commit: ship makes sound on exploding.

What’s left? The only thing I can think of is the “beat”, which was an alternating beep boop sound, the two sounds “beat1” and “beat2”. I think that is supposed to get faster as the game goes on. I’ll try to look up some info to see what it’s supposed to do before I do it. And it might be a bit tricky anyway, toggling two sounds. No sense spoiling an easy morning with something tricky.

Summary

I didn’t expect to learn much of anything this morning, but there are a few things.

Remove Duplication

Most important, we got a little repeated lesson in why we remove duplication. We had two places in the game where the ship explodes and gets removed, and as the code stood, they both needed to have sound added, and I found the one and didn’t think of the other until I noticed during play. It would be a shame to burn that into ROM and ship it to a few thousand pinball parlors. I fixed the problem by removing the duplication, having the one entry point call the other.

Help the Object’s User

We learned a bit about the difference between what makes sense on the inside of an object and on the outside. We discovered that our feature of letting callers specify how many channels they could use was a mis-feature. What our users needed was the ability, in only one case, to specify that one particular sound should not be overlayed.

Even if you have to change things

So we refactored our interface to allow for that.

Type-checking is Useful

We learned while making that change that a language with compile-time type specification makes changing calling sequences easier. In our case it wasn’t terribly difficult, but in a larger program we would probably have had to maintain the old interface and provide a different easier one.

Make Change Easy

And we learned that when the code is well-factored (and does not contain duplication) changes like plugging sound in are quite straightforward.

All good things.

We might speculate about a larger system. Even here, I’ve plugged in calls to play in 7 different places: one for asteroids, three for saucers, and three for ships. If we had many many objects each of which needed a sound, this task would have been much harder, as in each case we did have to hunt down where to put the sound, and in one case, actually missed a spot.

In a much larger system with the same basic style as we have here, there’d be little choice but to scan all the objects to see where sounds should be triggered. What we might do, just might, would be to trigger events in our objects, either when something was done to them, or when they do something.

For example, even here we have our Collider object that knows when things are being destroyed:

class Collider:
    def mutual_destruction(self, target, targets, attacker, attackers):
        if self.within_range(target, attacker):
            self.score += target.score_for_hitting(attacker)
            self.score += attacker.score_for_hitting(target)
            attacker.destroyed_by(target, attackers, self.fleets)
            target.destroyed_by(attacker, targets, self.fleets)
            return True
        else:
            return False

We could centralize all the explosion sounds here, probably with some kind of table lookup to pick the sounds.

Similarly, for ongoing sounds like the acceleration sound or the warbling of the saucer, we might be able to centralize some of the sounds in the tick message.

I think it likely that with any reasonable design, a change like this one can be done readily: we just have to figure out how. It might be interesting to consider how we could have done this one with less impact on the individual objects. However, in this case, there were only three that needed to be touched. A more centralized approach would not likely pay off.

But … if I had thought ahead of time that this might be a pervasive set of changes … might I have come up with something simpler?

Worth thinking about. Maybe even worth doing it over? We’ll see.

See you next time! I have to figure out how to record these sounds for you.