Python 64 - A little bit more ...
We must be pretty close to done in our quest to move responsibilities down to Fleets, Fleet, and flyers. Also some generic Python thoughts.
Here’s the asteroids_tick
:
class Game:
def asteroids_tick(self, delta_time):
self.fleets.tick(delta_time)
self.check_ship_spawn(self.ship, self.ships, delta_time)
self.check_next_wave(delta_time)
self.control_game(self.ship, delta_time)
self.process_collisions()
self.draw_everything()
if self.game_over: self.draw_game_over()
OK, ship spawning and wave creation are left. Let’s look a bit more deeply at those. Here’s the wave stuff …
class Game:
def check_next_wave(self, delta_time):
if not self.asteroids:
self.wave_timer.tick(delta_time, self.asteroids)
def init_wave_timer(self):
# noinspection PyAttributeOutsideInit
self.wave_timer = Timer(u.ASTEROID_DELAY, self.create_wave)
def create_wave(self, asteroids):
asteroids.extend([Asteroid() for _ in range(0, self.next_wave_size())])
And here’s the ship stuff …
class Game:
def check_ship_spawn(self, ship, ships, delta_time):
if ships: return
if self.ships_remaining <= 0:
self.game_over = True
return
self.ship_timer.tick(delta_time, ship, ships)
def set_ship_timer(self, seconds):
self.ship_timer = Timer(seconds, self.spawn_ship_when_ready)
def spawn_ship_when_ready(self, ship, ships):
if not self.safe_to_emerge(self.missiles, self.asteroids):
return False
ship.reset()
ships.append(ship)
self.ships_remaining -= 1
return True
def safe_to_emerge(self, missiles, asteroids):
if missiles: return False
for asteroid in asteroids:
if asteroid.position.distance_to(u.CENTER) < u.SAFE_EMERGENCE_DISTANCE:
return False
return True
The ship rezzing looks more difficult, if only because we need to communicate game_over
up to Game, who displays the game-over screen. Of course, we could move that responsibility elsewhere, but even so, that makes ship rezzing more difficult. It seems likely to me that a first step on ship rezzing might actually be to move the game over display elsewhere. Maybe it should be a kind of Fleet. We’ll need to move the ships_remaining
logic down as well.
Definitely harder. In a spirit of adventure … we’ll do the wave first. We’re adventurers, but prudent ones.
Asteroid Waves
It would seem reasonable to put this logic in AsteroidFleet, if we had such a class. It would be well-equipped to know whether there are any asteroids, and to pop out some new ones as needed.
One issue that I’ve had in previous timer-moving steps has been that my tests broke and I didn’t have any new tests for the new scheme. Let’s get serious this morning, and do some TDD on AsteroidFleet.
def test_asteroid_fleet(self):
asteroids = []
fleet = AsteroidFleet(asteroids)
This is enough to fail. So it’s enough to code:
class AsteroidFleet(Fleet):
def __init__(self, asteroids):
super().__init__(asteroids)
At first I’m confused by why my test is still failing, but it’s because I haven’t imported the class yet. As soon as I do that, the first AsteroidFleet test runs. Woot! Commit: initial AsteroidFleet test and class.
In creating that amazing code, I noticed this init:
class SaucerFleet(Fleet):
def __init__(self, flyers):
super().__init__(flyers)
self.timer = Timer(u.SAUCER_EMERGENCE_TIME, self.bring_in_saucer)
def bring_in_saucer(self):
self.flyers.append(Saucer())
return True
def tick(self, delta_time, fleets):
super().tick(delta_time, fleets)
if not self.flyers:
self.timer.tick(delta_time)
return True
That kind of suggests how we might proceed here, doesn’t it? I wonder if I have a test that I could crib. Matter of fact, I have:
def test_saucer_spawn(self):
saucers = []
fleets = Fleets([], [], saucers, [], [])
saucer_fleet = fleets.saucers
saucer_fleet.tick(0.1, fleets)
assert not saucers
saucer_fleet.tick(u.SAUCER_EMERGENCE_TIME, fleets)
assert saucers
Might as well follow that pattern, it seems sensible.
def test_asteroid_wave(self):
asteroids = []
fleets = Fleets(asteroids, [], [], [], [])
asteroid_fleet = fleets.asteroids
asteroid_fleet.tick(0.1, fleets)
assert not asteroids
asteroid_fleet.tick(u.ASTEROID_DELAY, fleets)
assert asteroids
That test passes the first assert, no surprise, and fails the second, also no surprise. Let’s make it go. I try this:
class AsteroidFleet(Fleet):
def __init__(self, asteroids):
super().__init__(asteroids)
self.timer = Timer(u.ASTEROID_DELAY, self.create_wave)
def create_wave(self):
self.append(Asteroid())
def tick(self, delta_time, fleets):
super().tick(delta_time, fleets)
if not self.flyers:
self.timer.tick(delta_time)
return True
I figured creating one Asteroid would pass the test. But the test, he is not passing. Why not? It seems clear that we never got to create_wave
.
Ah. I have to change the Fleets constructor:
class Fleets:
def __init__(self, asteroids, missiles, saucers, saucer_missiles, ships):
self.fleets = (AsteroidFleet(asteroids), Fleet(missiles), SaucerFleet(saucers), Fleet(saucer_missiles), ShipFleet(ships))
With that in place, I’m green. Commit: AsteroidFleet installed in Fleets. Rezzes one asteroid.
I can’t resist trying this in the game. I should be that I can remove the check in Game and get a game with one asteroid per wave. I can win this one.
That actually works. I still can’t win. I crashed into the last tiny asteroid, and the saucer shot me down. I am much better at programming this game than I am at winning it.
I can’t commit this code, but I can go ahead and improve my tests.
I find this test that already exists:
def test_wave_sizes(self):
game = Game(True)
game.asteroids_in_this_wave = 2
assert game.next_wave_size() == 4
assert game.next_wave_size() == 6
assert game.next_wave_size() == 8
assert game.next_wave_size() == 10
assert game.next_wave_size() == 11
assert game.next_wave_size() == 11
We’ll need to move that to our AsteroidFleet. I’ll just revise this test:
def test_wave_sizes(self):
fleet = AsteroidFleet([])
fleet.asteroids_in_this_wave = 2
assert fleet.next_wave_size() == 4
assert fleet.next_wave_size() == 6
assert fleet.next_wave_size() == 8
assert fleet.next_wave_size() == 10
assert fleet.next_wave_size() == 11
assert fleet.next_wave_size() == 11
There’s code to steal here, let’s steal it.
class AsteroidFleet(Fleet):
def __init__(self, asteroids):
super().__init__(asteroids)
self.timer = Timer(u.ASTEROID_DELAY, self.create_wave)
self.asteroids_in_this_wave = 2
def next_wave_size(self):
self.asteroids_in_this_wave += 2
if self.asteroids_in_this_wave > 10:
self.asteroids_in_this_wave = 11
return self.asteroids_in_this_wave
Test passes.
Let’s test that it’s used. I’ll expand the existing test:
def test_asteroid_wave(self):
asteroids = []
fleets = Fleets(asteroids, [], [], [], [])
asteroid_fleet = fleets.asteroids
asteroid_fleet.tick(0.1, fleets)
assert not asteroids
asteroid_fleet.tick(u.ASTEROID_DELAY, fleets)
assert len(asteroids) == 4
Fails, of course.
class AsteroidFleet:
def create_wave(self):
self.extend([Asteroid() for _ in range(0, self.next_wave_size())])
I am sure that this works correctly, but let’s extend that test a bit more so that it shows what’s really going on.
def test_asteroid_wave(self):
asteroids = []
fleets = Fleets(asteroids, [], [], [], [])
asteroid_fleet = fleets.asteroids
asteroid_fleet.tick(0.1, fleets)
assert not asteroids
asteroid_fleet.tick(u.ASTEROID_DELAY, fleets)
assert len(asteroids) == 4
asteroid_fleet.clear()
asteroid_fleet.tick(u.ASTEROID_DELAY, fleets)
assert len(asteroids) == 6
asteroid_fleet.clear()
asteroid_fleet.tick(u.ASTEROID_DELAY, fleets)
assert len(asteroids) == 8
asteroid_fleet.clear()
asteroid_fleet.tick(u.ASTEROID_DELAY, fleets)
assert len(asteroids) == 10
asteroid_fleet.clear()
asteroid_fleet.tick(u.ASTEROID_DELAY, fleets)
assert len(asteroids) == 11
asteroid_fleet.clear()
asteroid_fleet.tick(u.ASTEROID_DELAY, fleets)
assert len(asteroids) == 11
OK, we’re good. Clean up Game, deleting all the wave-related lines I can find. Tests are green. Game works. I manage to kill the first wave to see the second. Commit: asteroid wave creation handled by AsteroidFleet.
Let’s sum up.
Summary
That went just about as well as could be imagined. I was briefly confused by the fact that it’s the Fleets class that decides what class each Fleet should be:
class Fleets:
def __init__(self, asteroids, missiles, saucers, saucer_missiles, ships):
self.fleets = (AsteroidFleet(asteroids), Fleet(missiles), SaucerFleet(saucers), Fleet(saucer_missiles), ShipFleet(ships))
There’s a good chance that we’re done with specialized Fleet classes, because missile timing is done in Missile, not the fleet, and the Ship and Saucer classes determine whether they can fire, so I think there’s not much that a specialized Fleet class could do for us. So the problem will probably not come up again.
What could we do if we had a perennial problem of getting the wrong class in Fleets? Maybe could create all the specialized classes and leave them essentially empty, and plug them in once and for all. Maybe we could devise a standard way of testing. For example:
def test_asteroid_wave(self):
asteroids = []
fleets = Fleets(asteroids, [], [], [], [])
asteroid_fleet = fleets.asteroids
assert isinstance(asteroid_fleet, AsteroidFleet)
Once we set up that pattern, there’s a decent chance that we’d follow it in future tests.
In any case, I think I’ve probably made this particular mistake for the last time, at least regarding Fleets. (Famous last words?)
I think there are a few reasons why this went so smoothly. One of them is not how smart I am.
- It’s a relatively simple case;
- We drove it with tests, increasing difficulty slowly;
- That caused us to go in tiny steps;
- Most of the code already existed and could be copied.
At least two of those are always possible, numbers 2 and 3. And we can often, if not usually, slice down a feature into simple cases.
As for existing code, there’s always the possibility of doing spikes to experiment with the code until we get something we like.
Almost seems like I should advise myself to do things all the time like I did them today. But I so rarely listen …
Python Thoughts
In my copious free time I often browse Real Python and other Python sources on the web, and I’ve learned that there is a great deal of capability in Python that I’ve used very little or not at all, including but not limited to:
- List comprehensions - used a time or two;
- Class variables and methods - used a time or two;
- Static methods - used, but I don’t really get the point;
- Decorators - used only for class methods, static methods, and properties, no decorators of my own;
- Typing hints - I think PyCharm may make good use of these, and I should probably try them out;
- Descriptors - whatever they really are;
- The mass of “dunder” methods - used just a few, like
__len__
; - Magic things like
__slots__
- serious over-optimization in most cases?
Python is deep, and it’s impressive that it is not as much of a terrible mess as it might be. It’s a credit to Guido van Rossum and the rest of the Python community that it is as coherent as it is. It’s still a bit of a mess, in my view, but generally speaking, you don’t need to dig into the tricky bits very often.
That said, there’s a lot to get your arms around to really consider yourself a Python expert. I am far from that myself, but I can pretty much make it do whatever I need to do.
Which suggests an invitation: if you see things in these articles where there’s a better way or more interesting way, do feel free to toot me up. Tweets might work, but I am sort of withdrawing from Twitter. I’m ronjeffries@mastodon.social, at least just now. I could imagine that I might move instances at some point.
Bottom Line
It went well today. I feel like I might not be a total loss. I hope you agree.
See you next time!