Python 022 - Making Waves?
I think it’s time for asteroid waves. Let’s review stories. We create some questionable code and question it. It confesses and tries to reform. What do you think?
Stories May Include …
Two articles ago, I listed these:
-
Ship should explode; -
Ship should reappear after an interval, at center screen; -
Ship should appear only when center screen is relatively safe; - Saucer, with all its works and all its pomps;
- Multiple waves of asteroids;
- Score should appear on screen;
- Number of remaining ships should appear on screen.
- Tests.
We’ve done number 1, 2, and 3, and improved on number 8. Well done, team! Yesterday, after the work was done, I managed to kill off all the asteroids. I don’t remember how many ships it took because we haven’t done number 7. But I did run out of asteroids. So let’s work on making waves.
The story goes like this:
The game starts with four asteroids. Whenever all the asteroids are gone, after a discreet interval, more asteroids are created. The interval should be longer than the ship’s spawning interval by at least one second. Asteroids always start from the edges of the screen. Asteroid waves increase in size, 4, 6, 8, 10, 11, 11, …
Let’s start with checking the wave sizes, because it’s probably easy and it’s good to ease into things in the morning.
def test_wave_sizes(self):
game_init()
assert next_wave_size() == 4
assert next_wave_size() == 6
assert next_wave_size() == 8
assert next_wave_size() == 10
assert next_wave_size() == 11
assert next_wave_size() == 11
game_init()
I think we’d best call game_init or something. What we have here is a sign that we shouldn’t be running the game from main but should instead have a game object. Let’s add that to Jira. Oh, it’s already there. Good.
There is no function named next_wave_size, so let’s write it.
ship = Ship(pygame.Vector2(u.SCREEN_SIZE / 2, u.SCREEN_SIZE / 2))
ships = [ship]
asteroids = [Asteroid(2) for i in range(0, 4)]
missiles = []
ship_timer = 0
running = False
dt = 0
clock = pygame.time.Clock()
asteroids_in_this_wave = 2
def game_init():
global screen, clock, running, dt, asteroids_in_this_wave
pygame.init()
screen = pygame.display.set_mode((u.SCREEN_SIZE, u.SCREEN_SIZE))
pygame.display.set_caption("Asteroids")
clock = pygame.time.Clock()
asteroids_in_this_wave = 2
running = True
dt = 0
def next_wave_size():
global asteroids_in_this_wave
asteroids_in_this_wave += 2
if asteroids_in_this_wave > 10:
asteroids_in_this_wave = 11
return asteroids_in_this_wave
That’ll do it. Now let’s make the game do it. And let’s begin by not creating asteroids up there in the beginning of main:
ship = Ship(pygame.Vector2(u.SCREEN_SIZE / 2, u.SCREEN_SIZE / 2))
ships = [ship]
asteroids = []
missiles = []
ship_timer = 0
running = False
dt = 0
clock = pygame.time.Clock()
asteroids_in_this_wave = 2
That may have broken a test, I’m not sure yet. Anyway …
I didn’t notice until just now but the game has again started to run when I run the tests. It just draws the screen and immediately stops, but why is it running at all?
The calls to game_init
are doing it. I can remove those, because the game will init itself anyway. There’s something here that I don’t understand, however. Why would calling game_init start the game?
Let’s move on, to checking the wave in the game.
I’ve put in a call to check_next_wave, but I’m not quite sure how to do it. We can detect that there are no more asteroids easily enough. But the first time we detect it, we’d like to set the timer and thereafter not set it again, instead counting down until it’s zero, then create the asteroids.
Let me see if I can sketch this. Yes:
def check_next_wave(asteroids, dt):
global wave_timer
if asteroids: return
if wave_timer == -1:
wave_timer = 4
else:
wave_timer -= dt
if wave_timer <= 0:
for i in range(0, next_wave_size()):
asteroids.append(Asteroid())
wave_timer = -1
In game_init, we init wave_timer
to -1. If there are asteroids, we’re happy and return. Otherwise, we need asteroids in a while. We discover the timer is at -1, so we set it to 4 (soon to be a u
constant). Thereafter, we count down the timer and when it gets to zero, we create new asteroids and set the timer back to -1 for next time.
This works as advertised. There are things not to like, like the magic value -1. Also we can do better than individual appends, I think. How about this:
def check_next_wave(asteroids, dt):
global wave_timer
if asteroids: return
if wave_timer == -1:
wave_timer = u.ASTEROID_DELAY
else:
wave_timer -= dt
if wave_timer <= 0:
asteroids.extend([Asteroid() for _ in range(0, next_wave_size())])
wave_timer = -1
Now we have a named constant for the time, and we use extend to add in our new asteroids, which is allegedly faster. Is it as clear as the loop? There could be disagreement on that but we use list comprehensions here chez Ron.
The -1 trick on wave_timer
still bothers me. Would we prefer that it be None or a number? Let’s try it:
wave_timer = None
def check_next_wave(asteroids, dt):
global wave_timer
if asteroids: return
if wave_timer:
wave_timer -= dt
if wave_timer <= 0:
asteroids.extend([Asteroid() for _ in range(0, next_wave_size())])
wave_timer = None
else:
wave_timer = u.ASTEROID_DELAY
I think my friends won’t like this either way, but it makes at least a bit of sense to say “if there is no wave_timer, set a wave_timer”. I’ll submit this for code review. Tests are green and the game works as intended. Commit: Asteroid waves implemented per story.
Let’s reflect.
Reflection
Would we prefer that check method with the if reversed? It’s common to put the shorter phrase first, but we’d have a not
:
def check_next_wave(asteroids, dt):
global wave_timer
if asteroids: return
if not wave_timer:
wave_timer = u.ASTEROID_DELAY
else:
wave_timer -= dt
if wave_timer <= 0:
asteroids.extend([Asteroid() for _ in range(0, next_wave_size())])
wave_timer = None
Yes, I think that’s better. Things happen in logical order: first we have asteroids; then we have no timer; then we run the timer; then we create the asteroids and lose the timer.
Yes. better. Commit: refactor check_next_wave.
We could even do if not asteroids
. Let’s look at that form.
def check_next_wave(asteroids, dt):
global wave_timer
if not asteroids:
if not wave_timer:
wave_timer = u.ASTEROID_DELAY
else:
wave_timer -= dt
if wave_timer <= 0:
asteroids.extend([Asteroid() for _ in range(0, next_wave_size())])
wave_timer = None
I should mention that PyCharm did both those transformations for me, so I know they’re good. The code above is a bit overly nested. We could extract some methods. Let’s try one:
def check_next_wave(asteroids, dt):
global wave_timer
if not asteroids:
if not wave_timer:
wave_timer = u.ASTEROID_DELAY
else:
create_wave_in_due_time(asteroids, dt)
def create_wave_in_due_time(asteroids, dt):
global wave_timer
wave_timer -= dt
if wave_timer <= 0:
asteroids.extend([Asteroid() for _ in range(0, next_wave_size())])
wave_timer = None
I like the look of that better … but PyCharm is concerned. It underlines the wave_timer
in the final if statement with a warning that it expected an int and found a None. It doesn’t like the trick of setting the int to None.
But I do like this code layout. Let’s go back to the -1 but with a named constant.
ASTEROID_DELAY = 4
ASTEROID_SPEED = pygame.Vector2(100,0)
ASTEROID_TIMER_STOPPED = -1
def check_next_wave(asteroids, dt):
global wave_timer
if not asteroids:
if wave_timer == u.ASTEROID_TIMER_STOPPED:
wave_timer = u.ASTEROID_DELAY
else:
create_wave_in_due_time(asteroids, dt)
def create_wave_in_due_time(asteroids, dt):
global wave_timer
wave_timer -= dt
if wave_timer <= 0:
asteroids.extend([Asteroid() for _ in range(0, next_wave_size())])
wave_timer = u.ASTEROID_TIMER_STOPPED
OK, best of both worlds? Is -1 good enough? We could just as well use -9999 or +1321. It’s conceivable that a sufficiently slow computer would tick us from just a bit positive down to -1. Sure, let’s use a very negative number, it’s free.
ASTEROID_TIMER_STOPPED = -9999
Commit: Use large negative number as flag for ASTEROID_TIMER_STOPPED.
OK, the code is nearly good now. Let’s …
Reflect Further
The main file / module / whatever Python calls it is getting pretty messy. It’s 159 lines long and contains no less than 11 functions and at least ten global variables. It’s getting hard to find things. I think I’ll at least alphabetize the functions. Hold on a moment while I do that.
I do a bit of refactoring, just some extract functions, then put game_init and main_loop at the top, and everything else in alpha order below those. I’ll include main as an appendix here.
My reordering doesn’t affect the tests but when I run the game I get this:
create_wave_in_due_time(asteroids, dt)
File "/Users/ron/PycharmProjects/firstGo/main.py", line 113, in create_wave_in_due_time
wave_timer -= dt
TypeError: unsupported operand type(s) for -=: 'NoneType' and 'int'
Have I broken the init of wave_timer? Yes, I’ll fix it:
def game_init():
global screen, clock, running, dt, asteroids_in_this_wave
global wave_timer
pygame.init()
screen = pygame.display.set_mode((u.SCREEN_SIZE, u.SCREEN_SIZE))
pygame.display.set_caption("Asteroids")
clock = pygame.time.Clock()
asteroids_in_this_wave = 2
wave_timer = u.ASTEROID_TIMER_STOPPED
running = True
dt = 0
With that, we’re good. Commit: tidying main. Ensure wave_timer init correct.
I think we’ve done enough for the morning, we’re closing in on 300 lines of article. Let’s sum up.
Summary
Another story down and it actually went pretty smoothly. I am about 85% pleased with the wave timer and its STOPPED value. I will invite my colleagues to comment and of course you are welcome to provide feedback as well. ronjeffries at mastodon dot social is better. I’m weaning myself from Twitter as it is becoming more and more of a cesspool every day. By my standards, YMMV of course.
The main is getting large, and I do think it should be an object rather than a bunch of top-level functions, not least because it has all those globals.
I don’t see much that should be broken out from main, making it more than one object, but a separate focused look at it may bring things to mind.
I welcome feedback, of course. See you next time!
Appendix: Main
# Example file showing a circle moving on screen
from pygame import Vector2
from asteroid import Asteroid
import pygame
from ship import Ship
import u
ship = Ship(pygame.Vector2(u.SCREEN_SIZE / 2, u.SCREEN_SIZE / 2))
ships = [ship]
asteroids = []
missiles = []
ship_timer = 0
running = False
dt = 0
clock = pygame.time.Clock()
asteroids_in_this_wave = 2
wave_timer = None
def game_init():
global screen, clock, running, dt, asteroids_in_this_wave
global wave_timer
pygame.init()
screen = pygame.display.set_mode((u.SCREEN_SIZE, u.SCREEN_SIZE))
pygame.display.set_caption("Asteroids")
clock = pygame.time.Clock()
asteroids_in_this_wave = 2
wave_timer = u.ASTEROID_TIMER_STOPPED
running = True
dt = 0
def main_loop():
global running, ship, clock, dt
game_init()
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
check_ship_spawn(ship, ships, dt)
check_next_wave(asteroids, dt)
control_ship(ship, dt)
for missile in missiles.copy():
missile.update(missiles, dt)
move_everything(ship, dt)
check_collisions()
draw_everything()
pygame.display.flip()
dt = clock.tick(60) / 1000
pygame.quit()
def check_asteroids_vs_missiles():
for asteroid in asteroids.copy():
for missile in missiles.copy():
asteroid.collide_with_attacker(missile, missiles, asteroids)
def check_asteroids_vs_ship():
for ship in ships.copy(): # there's only one, do it first
for asteroid in asteroids.copy():
asteroid.collide_with_attacker(ship, ships, asteroids)
if not ships:
set_ship_timer(u.SHIP_EMERGENCE_TIME)
return
def check_collisions():
check_asteroids_vs_ship()
check_asteroids_vs_missiles()
def check_next_wave(asteroids, dt):
global wave_timer
if not asteroids:
if wave_timer == u.ASTEROID_TIMER_STOPPED:
wave_timer = u.ASTEROID_DELAY
else:
create_wave_in_due_time(asteroids, dt)
def check_ship_spawn(ship, ships, delta_time):
if ships: return
global ship_timer
ship_timer -= delta_time
if ship_timer <= 0 and safe_to_emerge(missiles, asteroids):
ship.reset()
ships.append(ship)
def control_ship(ship, dt):
keys = pygame.key.get_pressed()
if keys[pygame.K_f]:
ship.turn_left(dt)
if keys[pygame.K_d]:
ship.turn_right(dt)
if keys[pygame.K_j]:
ship.power_on(dt)
else:
ship.power_off()
if keys[pygame.K_k]:
ship.fire_if_possible(missiles)
else:
ship.not_firing()
def create_wave_in_due_time(asteroids, dt):
global wave_timer
wave_timer -= dt
if wave_timer <= 0:
asteroids.extend([Asteroid() for _ in range(0, next_wave_size())])
wave_timer = u.ASTEROID_TIMER_STOPPED
def draw_everything():
global ship
screen.fill("midnightblue")
for ship in ships:
ship.draw(screen)
for asteroid in asteroids:
asteroid.draw(screen)
for missile in missiles:
missile.draw(screen)
def move_everything(ship, dt):
if ship.active: ship.move(dt)
for asteroid in asteroids:
asteroid.move(dt)
for missile in missiles:
missile.move(dt)
def next_wave_size():
global asteroids_in_this_wave
asteroids_in_this_wave += 2
if asteroids_in_this_wave > 10:
asteroids_in_this_wave = 11
return asteroids_in_this_wave
def safe_to_emerge(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
def set_ship_timer(seconds):
global ship_timer
ship_timer = seconds
if __name__ == "__main__":
main_loop()