Python 118 - Attract Mode
We want the game to start in “Attract Mode”. Should be trivial. Shall we do a Seed? No, something more fun. Also: I make a mistake. Not the first time, just the most recent.
The game now comes up on the GAME OVER screen, with nothing else showing. When you actually do finish a game, you get the GAME OVER, but the Maker objects are still in the mix, so you continue to get asteroids and saucers, which makes for a nice display to attract people to put another quarter in the machine.
Let’s work through this a bit. When the game starts for the first time, it runs this code:
class Game:
def __init__(self, testing=False):
self.delta_time = 0
self.init_pygame_and_display(testing)
self.fleets = Fleets()
self.fleets.add_flyer(GameOver())
When a “q” is typed, the “Insert Quarter” command, we run this:
class Game:
def control_game(self):
keys = pygame.key.get_pressed()
if keys[pygame.K_q]:
self.fleets.add_flyer(Quarter())
So as things stand, Game knows two class names, GameOver and Quarter. It would be better if it didn’t know any. Failing that, it would be better if it knew only one. I do not see a way that I like to have it know nothing.
What if Quarter had a parameter on its constructor? Quarter looks like this:
class Quarter(Flyer):
def interact_with(self, other, fleets):
other.interact_with_quarter(self, fleets)
def draw(self, screen):
pass
def tick(self, delta_time, fleets):
fleets.clear()
fleets.add_flyer(SaucerMaker())
fleets.add_flyer(ScoreKeeper())
fleets.add_flyer(ShipMaker())
fleets.add_flyer(Thumper())
fleets.add_flyer(WaveMaker())
It doesn’t even have an __init__
. But it could and we could pass it a parameter of some kind to indicate whether this is a cold boot, or an actual quarter. All it has to do is not add the ShipMaker on the cold boot.
There are no tests for Quarter. It’s just too trivial, I couldn’t raise the gumption to write a test for something that dull.
Let’s do this:
class Quarter(Flyer):
def __init__(self, amount=25):
self.amount = amount
def tick(self, delta_time, fleets):
fleets.clear()
fleets.add_flyer(SaucerMaker())
fleets.add_flyer(ScoreKeeper())
fleets.add_flyer(Thumper())
fleets.add_flyer(WaveMaker())
if self.amount:
fleets.add_flyer(ShipMaker())
Commit: Quarter object accepts amount, defaults to 25 (non-zero) to add ShipMaker.
Now in Game, we just do this:
class Game:
def __init__(self, testing=False):
self.delta_time = 0
self.init_pygame_and_display(testing)
self.fleets = Fleets()
self.fleets.add_flyer(Quarter(0))
And Voila! We have attract mode as requested. Start the game and after 4 seconds you get asteroids, and every seven seconds the Saucer shows up and shoots some missiles and either crashes into an asteroid or sails off. Perfect.
Commit: “Attract Mode. Insert Quarter(0).”
Reflection
Most fascinating to me is that I thought that getting the game to enter attract mode at startup and to work correctly on “q” would be difficult. It wasn’t, only needing a couple of lines of code a few articles back. Then adding the Quarter object was easy. Now having two kinds of quarters was also easy.
Why is that interesting? Well, because it says that the objects are really close to right, and in some ways even closer than I imagined. So it’s good news when things are easy.
I think we can do better than this Quarter(0)
stuff. Just for fun:
class Quarter(Flyer):
@classmethod
def quarter(cls):
return cls(25)
@classmethod
def slug(cls):
return cls(0)
And in Game:
class Game:
def __init__(self, testing=False):
self.delta_time = 0
self.init_pygame_and_display(testing)
self.fleets = Fleets()
self.fleets.add_flyer(Quarter.slug())
def control_game(self):
keys = pygame.key.get_pressed()
if keys[pygame.K_q]:
self.fleets.add_flyer(Quarter.quarter())
One more thing, still just for fun. Rename Quarter class to Coin. That requires me to change a couple of interact_with
methods, but it’s worth it just for this:
class Game:
def __init__(self, testing=False):
self.delta_time = 0
self.init_pygame_and_display(testing)
self.fleets = Fleets()
self.fleets.add_flyer(Coin.slug())
def control_game(self):
keys = pygame.key.get_pressed()
if keys[pygame.K_q]:
self.fleets.add_flyer(Coin.quarter())
Clearly worth it. Commit: Game starts game with Coin.slug(), “q” command does Coin.quarter().
If we can’t have fun in our work, we should hire a fake AI to do it.
Anyway, as I was saying, this change was all quite easy. And I think that as it stands, it’s a good convention to say that for any game, we “just” have to create our independent objects and implement Coin.slug()
and Coin.quarter()
to load the mix with the appropriate starting group of objects. As long as we do that, Game will “just work”.
Are we there yet?
Pretty soon. One thing that is still being done by Game that should not be is the display of available ships. Let’s see about moving that to ScoreKeeper, where it probably belongs.
class Game:
available_ship = Ship(Vector2(0, 0))
available_ship._angle = 90
def draw_available_ships(self):
for i in range(0, self.fleets.ships_remaining):
self.draw_available_ship(self.available_ship, i)
Huh. On the face of it, we could almost just move all that to ScoreKeeper. It would have to fetch the available ships during a tick
or something, since there’s no access to fleets during draw. But how does Fleets know about this?
class Fleets:
ships_remaining = u.SHIPS_PER_QUARTER
class ShipMaker:
def create_ship(self, fleets):
if not self._safe_to_emerge:
return False
if fleets.ships_remaining > 0:
fleets.ships_remaining -= 1
fleets.add_flyer(Ship(u.CENTER))
else:
fleets.add_flyer(GameOver())
self._game_over = True
return True
OK, that’s just weird. We were just moving the info out of Game and only got as far as pushing it into Fleets. I think what we need to do is move the information into ShipMaker, who actually needs it, and then ScoreKeeper can save a pointer to ShipMaker and ask it for the count when it’s time to draw.
Let’s see if we have some decent ShipMaker tests. We have, notably this one:
def test_can_run_out_of_ships(self):
fleets = Fleets()
fleets.ships_remaining = 2
fleets.add_flyer(ShipMaker())
interactor = Interactor(fleets)
fi = FI(fleets)
interactor.perform_interactions()
fleets.tick(u.SHIP_EMERGENCE_TIME)
assert fi.ships
assert fleets.ships_remaining == 1
for ship in fi.ships:
fleets.remove_flyer(ship)
interactor.perform_interactions()
fleets.tick(u.SHIP_EMERGENCE_TIME)
assert fi.ships
assert fleets.ships_remaining == 0
assert not fi.game_over
for ship in fi.ships:
fleets.remove_flyer(ship)
interactor.perform_interactions()
fleets.tick(u.SHIP_EMERGENCE_TIME)
assert not fi.ships
assert fi.game_over
This is too invasive and not robust enough for our needs, but the shell is good. I’ll change this test to be ignorant of the count, just repeatedly destroy and recreate ships. It’ll probably be more clear that way anyway.
We’ll have to run through all four ships, but that’s no problem:
def test_can_run_out_of_ships(self):
fleets = Fleets()
fleets.add_flyer(ShipMaker())
interactor = Interactor(fleets)
fi = FI(fleets)
for _ in range(4):
interactor.perform_interactions()
fleets.tick(u.SHIP_EMERGENCE_TIME)
assert fi.ships
assert not fi.game_over
for ship in fi.ships:
fleets.remove_flyer(ship)
interactor.perform_interactions()
fleets.tick(u.SHIP_EMERGENCE_TIME)
assert not fi.ships
assert fi.game_over
So that’s much nicer. Rez four ships and remove them. Try one more and fail.
Commit: improve test.
Now we can break ShipMaker and fix it back up and we’ll be sure it’s working. We do get a new ShipMaker on each quarter, so the count is straightforward.
class ShipMaker(Flyer):
def __init__(self):
self._ships_remaining = u.SHIPS_PER_QUARTER
self._timer = Timer(u.SHIP_EMERGENCE_TIME)
self._game_over = False
self._need_ship = True
self._safe_to_emerge = False
def create_ship(self, fleets):
if not self._safe_to_emerge:
return False
if self._ships_remaining > 0:
self._ships_remaining -= 1
fleets.add_flyer(Ship(u.CENTER))
else:
fleets.add_flyer(GameOver())
self._game_over = True
return True
Our modified test broke and then recovered. So I’m sure we only get four ships … and I’m also sure that the game display will not count them down.
I run the game to check the count and I note that the GAME_OVER does not come up at the beginning of the run. What’s up with that?
I have no idea. These changes were easy enough. Roll back to be sure it was working after changing that test. It was not. Weird.
Ah. We do this in Coin:
class Coin(Flyer):
def tick(self, delta_time, fleets):
fleets.clear()
fleets.add_flyer(SaucerMaker())
fleets.add_flyer(ScoreKeeper())
fleets.add_flyer(Thumper())
fleets.add_flyer(WaveMaker())
if self.amount:
fleets.add_flyer(ShipMaker())
We used to add the ShipMaker with the ship count set to zero. I wonder if I shipped this broken (or not implemented)? Anyway:
class Coin(Flyer):
def tick(self, delta_time, fleets):
fleets.clear()
fleets.add_flyer(SaucerMaker())
fleets.add_flyer(ScoreKeeper())
fleets.add_flyer(Thumper())
fleets.add_flyer(WaveMaker())
if self.amount:
fleets.add_flyer(ShipMaker())
else:
fleets.add_flyer(GameOver())
Test. Commit: Put back GAME_OVER on startup.
I’m going too fast. Should slow down.
Anyway, we know how to do the ShipMaker, so we’ll do that again. Just as before. Game is now working except for the display of ships remaining. I’m sorry that I made that member private. Change it:
class ShipMaker(Flyer):
def __init__(self):
self.ships_remaining = u.SHIPS_PER_QUARTER
self._timer = Timer(u.SHIP_EMERGENCE_TIME)
self._game_over = False
self._need_ship = True
self._safe_to_emerge = False
Now let’s remove the ship drawing from Game entirely, and move the draw code over to ScoreKeeper:
class ScoreKeeper(Flyer):
available_ship = Ship(Vector2(0, 0))
available_ship._angle = 90
def __init__(self):
self.score = 0
self._ship_maker = None
if pygame.get_init():
self.score_font = pygame.font.SysFont("arial", 48)
def draw(self, screen):
score_surface, score_rect = self.render_score()
screen.blit(score_surface, score_rect)
self.draw_available_ships(screen)
def draw_available_ships(self, screen):
if self._ship_maker:
for i in range(0, self._ship_maker.ships_remaining):
self.draw_available_ship(self.available_ship, i, screen)
def draw_available_ship(self, ship, i, screen):
position = i * Vector2(35, 0)
ship.move_to(Vector2(55, 100) + position)
ship.draw(screen)
We have to check whether we have a ShipMaker, because in slug mode, we do not have one. I found that the hard way.
Let’s fix that so that we don’t have to check.
@dataclass
class NoShips:
ships_remaining:int = 0
class ScoreKeeper(Flyer):
available_ship = Ship(Vector2(0, 0))
available_ship._angle = 90
def __init__(self):
self.score = 0
self._ship_maker = NoShips()
if pygame.get_init():
self.score_font = pygame.font.SysFont("arial", 48)
def draw_available_ships(self, screen):
for i in range(0, self._ship_maker.ships_remaining):
self.draw_available_ship(self.available_ship, i, screen)
I used dataclass
to make a little fake class with a property ships_remaining
, to match the ShipMaker we will normally have, so that we can always just fetch the property.
I’ve never used dataclass
before, so it seemed worth trying. I’m not sure if there is a simpler way to build a dummy object but this one works fine.
Commit: use fake NoShips object to eliminate check for ShipMaker existence.
Reflection
What I just did there is, I believe, often called the Null Object Pattern, where, instead of checking for presence of an object, you initialize with a “null object” that implements the behavior of the real thing, in a harmless basically do nothing kind of way.
Is this simpler than just checking for self._ship_maker
existing? It took a few more lines … but they are not in the main execution flow. Reading the draw code is easier, by a small margin. I’d say that if your team is used to NullObject, they’ll find it clear and useful and if they aren’t, well, maybe they should be. Anyway, you get to do you. This is how I do. This time.
Musing
Back when I used to hang out with Smalltalk people, if
statements were pretty much anathema. Oh, you’d use them in cases like
person age > 21
ifTrue: [person giveDrink(beer)]
ifFalse: [person giveDrink(cola)]
But even there some folx would think you might be better off with two different Person subclasses, instead of asking for their age.
This is making me wonder whether some other Null Objects might be useful in this code. I’ll try to stay alert for the possibility. I think I’ve developed some um inferior habits using these other languages. But I digress.
Summary
So. This morning we started by changing things so that the game comes up in “attract mode”, where asteroids fly and the saucer appears, but there are no ships. This was done with a parameter on Quarter, saying whether or not to build a ShipMaker.
Then we took a few steps to simplify Game’s connection to the Asteroids game, ultimately reaching a single object, Coin, with two methods slug
and quarter
, that the game can use to start or restart the game. We went a little wild there, just because it was fun to rename the class. Making the two class methods actually does make sense, because it makes the connection to the actual game more abstract. You can insert a slug and it’ll do whatever, or a coin and it’ll presumably play.
Then we moved the available ships drawing into ScoreKeeper. We did this in few phases. We improved a test so that it would work before we started, and would work again when we have moved the available ship logic to ShipMaker, where it seems to belong, since ShipMaker needs to check and update the value.
Then we changed ShipMaker to maintain the value.
With that in place, we were able to modify ScoreKeeper to notice ShipMaker during interactions, and to hold on to it, so that it can ask for the number of ships to display.
- Hmm
- I wonder whether it would be better to have ShipMaker display the ship count. It feels to me to be more of a scorekeeper function, but ShipMaker has the info. Interesting question.
However, I didn’t notice that something in that change sequence made the GAME OVER screen stop coming up in attract mode. When we unconditionally added ShipMaker, with zero ships, it immediately created a GameOver. When we switched to not inserting ShipMaker, no GameOver ever got created.
I’m not sure why I didn’t immediately notice it, but I didn’t. And there are no decent tests for this creation sequence, and shipping a bug, even for just a few moments, is clear evidence of that. I’ve made a note to improve that area.
Overall, the tests have served well, and almost every serious mistake I’ve made has been in an area with weak or zero testing. Coincidence? I think not.
Bottom line, though, we have removed the available ships code from Game, as well as one of its bits of knowledge of Asteroids objects. And we’ve removed the odd situation where Fleets knew available_ships just because it was a place it could be stashed.
Commit: Remove vestiges of available ship counting from game and fleets.
We’ll take a harder look tomorrow, but from a look at the imports, Game is pretty well disconnected from the actual game play:
game.py:
from coin import Coin
from fleets import Fleets
from interactor import Interactor
from sounds import player
import pygame
import u
I am pleased. I do enjoy playing with the clay to see what shapes I can make.
See you next time!