Python 55 - Still More Timers
In for a penny and all that, I do the Ship’s spawning Timer, somewhat the same but slightly different. I mention a Fundamental Learning.
Right.
# noinspection PyAttributeOutsideInit
def init_asteroids_game_values(self):
self.asteroids_in_this_wave: int
self.init_saucer_timer()
self.ship_timer = 0
self.ships_remaining = 0
self.init_wave_timer()
Pretty sure that ship_timer
there needs to be a timer. We’ll check the other references to it:
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 -= delta_time
if self.ship_timer <= 0 and self.safe_to_emerge(self.missiles, self.asteroids):
ship.reset()
ships.append(ship)
self.ships_remaining -= 1
def set_ship_timer(self, seconds):
if self.ship_timer <= 0:
self.ship_timer = seconds
What’s that last one?
class Game:
def process_collisions(self):
collider = Collider(asteroids=self.asteroids, missiles=self.missiles, saucers=self.saucers, saucer_missiles=self.saucer_missiles,
ships=self.ships)
self.score += collider.check_collisions()
if not self.ships:
self.set_ship_timer(u.SHIP_EMERGENCE_TIME)
def insert_quarter(self, number_of_ships):
self.asteroids = []
self.missiles = []
self.ships = []
self.asteroids_in_this_wave = 2
self.game_over = False
self.init_saucer_timer()
self.score = 0
self.ships_remaining = number_of_ships
self.set_ship_timer(u.SHIP_EMERGENCE_TIME)
self.init_wave_timer()
self.delta_time = 0
class TestCollisions:
def test_respawn_ship(self):
game = Game(testing=True)
game.ships_remaining = 3
ship = Ship(Vector2(0, 0))
ship.velocity = Vector2(31, 32)
ship.angle = 90
ships = []
game.set_ship_timer(3)
assert game.ship_timer == 3
game.check_ship_spawn(ship, ships, 0.1)
assert not ships
game.check_ship_spawn(ship, ships, u.SHIP_EMERGENCE_TIME)
assert ships
assert ship.position == u.CENTER
assert ship.velocity == Vector2(0, 0)
assert ship.angle == 0
You’d think that if we were going to have the set_ship_timer
thing, we’d use it all the time. However, with that if
in it, I can’t call it from the first usage init_asteroids_game_values
, because the if will find the timer unset. Let’s just plug in the timer., in the set method, and then call it. Tests will break for a while.
# noinspection PyAttributeOutsideInit
def init_asteroids_game_values(self):
self.asteroids_in_this_wave: int
self.init_saucer_timer()
self.set_ship_timer(u.SHIP_EMERGENCE_TIME)
self.ships_remaining = 0
self.init_wave_timer()
def set_ship_timer(self, seconds):
self.ship_timer = Timer(seconds, self.spawn_ship_when_ready)
We’re good in insert_quarter
. Any other references to ship_timer? Just here:
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 -= delta_time
if self.ship_timer <= 0 and self.safe_to_emerge(self.missiles, self.asteroids):
ship.reset()
ships.append(ship)
self.ships_remaining -= 1
We need to extract the working bit:
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 -= delta_time
self.spawn_ship_when_ready(ship, ships)
def spawn_ship_when_ready(self, ship, ships):
if self.ship_timer <= 0 and self.safe_to_emerge(self.missiles, self.asteroids):
ship.reset()
ships.append(ship)
self.ships_remaining -= 1
Then:
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 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
One test failing, but the game should be right. It isn’t: the ship never shows up. Better look at the test. I just needed to remove its reference to ship_timer
as an integer.
class TestCollisions:
def test_respawn_ship(self):
game = Game(testing=True)
game.ships_remaining = 3
ship = Ship(Vector2(0, 0))
ship.velocity = Vector2(31, 32)
ship.angle = 90
ships = []
game.set_ship_timer(u.SHIP_EMERGENCE_TIME)
game.check_ship_spawn(ship, ships, 0.1)
assert not ships
game.check_ship_spawn(ship, ships, u.SHIP_EMERGENCE_TIME)
assert ships
assert ship.position == u.CENTER
assert ship.velocity == Vector2(0, 0)
assert ship.angle == 0
That runs green. But why oh why is the ship not appearing when I put in my quarter?
It’s this:
def process_collisions(self):
collider = Collider(asteroids=self.asteroids, missiles=self.missiles, saucers=self.saucers, saucer_missiles=self.saucer_missiles,
ships=self.ships)
self.score += collider.check_collisions()
if not self.ships:
self.set_ship_timer(u.SHIP_EMERGENCE_TIME)
What is this exactly, belt and suspenders? What happens if we just remove that last bit?
Right, everything works fine. I have no idea why I put that check there. Commit: Game uses Timer for ship spawning.
Saucer Missile Timer
Are there more timers up in this thing? I don’t think so but I’ll search and explore. Searching for “timer”, I find this:
def missile_timer_expired(self, delta_time):
self.missile_timer -= delta_time
expired = self.missile_timer <= 0
return expired
That’s in saucer. Who calls it? Command+B.
def firing_is_possible(self, delta_time, saucer_missiles):
return self.missile_timer_expired(delta_time) and self.a_missile_is_available(saucer_missiles)
Who calls that?
def fire_if_possible(self, delta_time, saucer_missiles, ships):
if self.firing_is_possible(delta_time, saucer_missiles):
self.fire_a_missile(saucer_missiles, ships)
OK, who calls that?
if keys[pygame.K_k]:
ship.fire_if_possible(self.missiles)
OK, we have here a legitimate timing going on that’s not using the Timer. Can we ball-peen this until it does? I should think so.
Let’s look at the whole flow, it does go on and on …
def fire_if_possible(self, delta_time, saucer_missiles, ships):
if self.firing_is_possible(delta_time, saucer_missiles):
self.fire_a_missile(saucer_missiles, ships)
def firing_is_possible(self, delta_time, saucer_missiles):
return self.missile_timer_expired(delta_time) and self.a_missile_is_available(saucer_missiles)
def missile_timer_expired(self, delta_time):
self.missile_timer -= delta_time
expired = self.missile_timer <= 0
return expired
@staticmethod
def a_missile_is_available(saucer_missiles):
return len(saucer_missiles) < u.SAUCER_MISSILE_LIMIT
def fire_a_missile(self, saucer_missiles, ships):
saucer_missiles.append(self.create_missile(ships))
self.missile_timer = u.SAUCER_MISSILE_DELAY
There must be another setter for missile_timer
. Yes, in the Saucer’s __init__
.
Let’s just do our usual thing, creating set_missile_timer
and using it.
class Saucer:
def __init__(self, position=None, size=2):
self.position = position if position is not None else u.CENTER
self.size = size
self.velocity = u.SAUCER_VELOCITY
self.directions = (self.velocity.rotate(45), self.velocity, self.velocity, self.velocity.rotate(-45))
self.direction = -1
self.radius = 20
raw_dimensions = Vector2(10, 6)
saucer_scale = 4 * self.size
self.offset = raw_dimensions * saucer_scale / 2
saucer_size = raw_dimensions * saucer_scale
self.saucer_surface = SurfaceMaker.saucer_surface(saucer_size)
self.set_firing_timer()
self.set_zig_timer()
def set_firing_timer(self):
self.missile_timer = Timer(u.SAUCER_MISSILE_DELAY, self.fire_if_possible)
def ready(self):
self.direction = -self.direction
self.velocity = self.direction * u.SAUCER_VELOCITY
x = 0 if self.direction > 0 else u.SCREEN_SIZE
self.position = Vector2(x, random.randrange(0, u.SCREEN_SIZE))
self.set_firing_timer()
self.set_zig_timer()
That brings me here:
def fire_if_possible(self, delta_time, saucer_missiles, ships):
if self.firing_is_possible(delta_time, saucer_missiles):
self.fire_a_missile(saucer_missiles, ships)
def firing_is_possible(self, delta_time, saucer_missiles):
return self.missile_timer_expired(delta_time) and self.a_missile_is_available(saucer_missiles)
def missile_timer_expired(self, delta_time):
self.missile_timer -= delta_time
expired = self.missile_timer <= 0
return expired
I recall—actually I look to see that—the Timer is going to call fire_if_possible
, so we know that the timer will be expired, so we don’t have to check that … we don’t want to call fire_if_possible
, that’s where we want to tick. We want to call fire_if_missile_available
which will return T/F.
def set_firing_timer(self):
self.missile_timer = Timer(u.SAUCER_MISSILE_DELAY, self.fire_if_missile_available)
def fire_if_missile_available(self, saucer_missiles, ships):
if self.a_missile_is_available(saucer_missiles):
self.fire_a_missile(saucer_missiles, ships)
return True
else:
return False
Tests are red but this should—and does—work in the game.
I can remove some excess code:
def firing_is_possible(self, delta_time, saucer_missiles):
return self.missile_timer_expired(delta_time) and self.a_missile_is_available(saucer_missiles)
def missile_timer_expired(self, delta_time):
self.missile_timer -= delta_time
expired = self.missile_timer <= 0
return expired
Now about those tests:
class TestSaucer:
def test_ready(self):
saucer = Saucer()
saucer.ready()
assert saucer.position.x == 0
assert saucer.velocity == u.SAUCER_VELOCITY
assert saucer.missile_timer == u.SAUCER_MISSILE_DELAY
saucer.missile_timer = 0
saucer.ready()
assert saucer.position.x == u.SCREEN_SIZE
assert saucer.velocity == -u.SAUCER_VELOCITY
assert saucer.zig_timer.delay == u.SAUCER_ZIG_TIME
assert saucer.zig_timer.elapsed == 0
assert saucer.missile_timer == u.SAUCER_MISSILE_DELAY
Those checks for the SAUCER_MISSILE_DELAY won’t do. Shall we reach down in and check the timer? It shouldn’t be necessary, but let’s do:
This passes:
def test_ready(self):
saucer = Saucer()
saucer.ready()
assert saucer.position.x == 0
assert saucer.velocity == u.SAUCER_VELOCITY
assert saucer.missile_timer.elapsed == 0
saucer.missile_timer.elapsed = 0.5
saucer.ready()
assert saucer.position.x == u.SCREEN_SIZE
assert saucer.velocity == -u.SAUCER_VELOCITY
assert saucer.zig_timer.delay == u.SAUCER_ZIG_TIME
assert saucer.zig_timer.elapsed == 0
assert saucer.missile_timer.elapsed == 0
Pretty invasive but does check to be sure that ready
resets the missile_timer.
And the other test:
def test_can_only_fire_two(self):
saucer = Saucer()
saucer_missiles = []
assert saucer.missile_timer == u.SAUCER_MISSILE_DELAY
saucer.fire_if_possible(delta_time=0.1, saucer_missiles=saucer_missiles, ships=[])
assert not saucer_missiles
saucer.fire_if_possible(u.SAUCER_MISSILE_DELAY, saucer_missiles=saucer_missiles, ships=[])
assert len(saucer_missiles) == 1
assert saucer.missile_timer == u.SAUCER_MISSILE_DELAY
saucer.fire_if_possible(u.SAUCER_MISSILE_DELAY, saucer_missiles=saucer_missiles, ships=[])
assert len(saucer_missiles) == 2
saucer.fire_if_possible(u.SAUCER_MISSILE_DELAY, saucer_missiles=saucer_missiles, ships=[])
assert len(saucer_missiles) == 2
I think I’ll just remove the checks for the timer.
def test_can_only_fire_two(self):
saucer = Saucer()
saucer_missiles = []
saucer.fire_if_possible(delta_time=0.1, saucer_missiles=saucer_missiles, ships=[])
assert not saucer_missiles
saucer.fire_if_possible(u.SAUCER_MISSILE_DELAY, saucer_missiles=saucer_missiles, ships=[])
assert len(saucer_missiles) == 1
saucer.fire_if_possible(u.SAUCER_MISSILE_DELAY, saucer_missiles=saucer_missiles, ships=[])
assert len(saucer_missiles) == 2
saucer.fire_if_possible(u.SAUCER_MISSILE_DELAY, saucer_missiles=saucer_missiles, ships=[])
assert len(saucer_missiles) == 2
Perfectly good firing test. I think it could be simpler now but this way involves the Timer and that’s good. I could just call the Timer’s action, but I think this is a more solid test.
We are green. Commit: saucer missiles fired using Timer.
Reflection
Now I think I’ll look at references to delta_time to see if anyone is counting with it. There are lots of references, but only one that says += delta_time
. Everyone else calls a tick
with it, or multiplies it times a velocity or the like.
There are, I’m rather sure, no wayward timers who are not using Timer. I think that’s a good thing. Let’s sum up.
Summary
Making these changes has been pretty quick and generally easy, with one common exception that I’ll mention below. But it wasn’t done in a single change, extract this refactor that kind of way. It was basically programming, and I’d have preferred it to be more mechanical.
I don’t see how to have done that. So I programmed.
Common Exception
I’m troubled by the repeated trope of setting the timer in __init__
and then again in a ready
operation or insert_quarter
. I stumbled over that change at least twice and I think more times.
It would be better, I think, if the Timer had a reset
operation to set elapsed
back to zero, and if the ready
and insert_quarter
were to use that. But even the need to initialize it a second time at all is an issue but since the game can restart with the timers in any random state, resetting is the best idea I have so far. I think I’d have stumbled anyway.
Part of the problem is that because Python wants me to init everything at the beginning, and because I want to start at GAME OVER, I have to reset things when you put in your quarter. Maybe there’s a way around that. I’ll look for it … but not now.
We won’t even put in reset
now, just because we have another 400 line article here and no one wants even one of those in a day, much less two.
Fundamental Learning
What we are seeing here in this continuous stream of articles is a single key point: we can always, with a little bit of work, make the code better.
I’ll push this one and hope you’ll stop by next time! See you then!