Python 035 - Main Loop
I think it’s time to start moving the main loop code to an object. Very rambling and ambling flow this morning. Lots of thinking, small steps. One roll-back.
Here’s “Jira”, a transcription of the yellow sticky notes on my keyboard tray.
- Smart collections?
- Game object
- Main loop
- Globals etc
- Hyperspace
- Saucer
- Available Ship Display tuning
Yeah, I think cleaning up the code a bit makes sense. I’m sure that I want a Game object to run the game and own most of the live values, and I suspect that I’m not managing globals terribly well. I’ll try to do some reading about the latter subject, but life is dog-piling me right now, so I’m a bit slow off the mark on my homework.
Anyway, I think that what we want is a Game object that contains all the live variables, the space object collections and such, and has the insert_quarter method and the main loop method, plus, of course, all the other things that are now happening at the top level. And I’d like to do it incrementally, so that I can commit frequently. Imagine that we had a team here and some of the pairs were working on features, which of course we’d want to commit and deploy frequently. If I were to take a long time working on the game object, my branch would diverge more and more from the growing feature list, and when I was ready to ship it, the merge would be horrendous.
We resolve this merge issue, in my circles, by everyone committing often to head. We don’t really create branches at all, instead working in our own copy for just a little while and then committing it. Ideally, I’d be committing to head every 20 minutes or so, which would make the merges pretty easy. We might coordinate a bit, like calling out “could everyone stay out of the Foo module a bit, I’m going to put in the Frammis”, but usually we could just quickly merge with the current head, in case there’s something new up there, and then push our working tested code.
Now here chez Ron, that’s particularly easy to say, because no one else ever messes up the code. I’m the only one who messes up the code, so my local repo is generally in sync with the … what are we supposed to call it? Not master … I don’t remember. Anyway, in sync.
But I digress.
Small Steps
How can I proceed incrementally? I think there are 23 top-level functions, and I think all of them likely belong inside the game object. Can I create the Game class and move just a few things into it, adjusting a function or two at a time? Tests will have to adjust as things move inside game.
One p-baked idea1 for very low p: If there were a well-known game object, maybe we could leave the top level methods there and defer their implementation to that object. I don’t know, just a random thought.
Let’s try it. If it gets nasty I’ll roll back and try something else.
Game, Inch by Inch
I think what I’ll do is create the Game class and instantiate it in the __main__
code. Then I’ll look for something easy to do, maybe insert_quarter
.
Let’s get some code down, that will tell us what we really need. Speculation is too hard at this hour of the morning.
Shall we have some tests? Yes. I’ll create a test_game.py and put a test in it, which will perhaps encourage me to do more.
class TestGame:
def test_hookup(self):
assert False
Test fails. Good news. I’ll rename it and make it useful.
class TestGame:
def test_game_creation(self):
game = Game()
assert game
I’ll create a new file for the Game class, because I’m going to have to import it here and there.
from game import Game
class TestGame:
def test_game_creation(self):
game = Game()
assert game
class Game:
def __init__(self):
...
So far so good. Tests are green. I could commit this. Not yet. I’m not sure whether this scheme will hold water, though this much surely will. Let’s create a Game in main.
asteroids = []
asteroids_in_this_wave = 2
clock = pygame.time.Clock()
delta_time = 0
game: Game
game_over = False
...
if __name__ == "__main__":
game = Game()
main_loop()
Tests are green and this is surely harmless. Now let’s see if we can make insert_quarter work.
There’s this top-level function:
def control_ship(ship, dt):
keys = pygame.key.get_pressed()
if keys[pygame.K_q]:
insert_quarter(u.SHIPS_PER_QUARTER)
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()
Let’s change it to this:
def control_ship(ship, dt):
keys = pygame.key.get_pressed()
if keys[pygame.K_q]:
game.insert_quarter(u.SHIPS_PER_QUARTER)
...
That causes PyCharm to tell me there is no such method in game, and it offers to help me add one. I allow it:
def insert_quarter(self, SHIPS_PER_QUARTER):
pass
Sure, leave the hard part for me. OK, I’m up to it. My cunning plan is to call the top level function.
from main import insert_quarter
class Game:
def __init__(self):
...
def insert_quarter(self, number_of_ships):
insert_quarter(number_of_ships)
I expect that the game will work. But some tests are broken, which I admit surprises me a bit.
Arrgh, it’s that horrible looping include thing:
E ImportError: cannot import name 'insert_quarter' from partially initialized module 'main' (most likely due to a circular import) (/Users/ron/PycharmProjects/firstGo/main.py)
Main imports game and game is trying to import main. Bummer. Remember what I said above about not having a good sense of how best to manage globals? That’s what’s happening right here. I’m really not sure how best to proceed. I think we need to have a common thing that both main and Game can import. Let’s do that. We’ll move all the top level functions to a file named top
that we can both import. That might work. If not, I’m going to need a whole new idea.
I can’t make it work. Pardon me: I have not been able to make this work, and I don’t have another idea on this path.
I’ll roll back and start again. I roll back everything except test_game, which is now failing. Hmm, it left my game.py file as well. But it doesn’t show up as needing to be committed. That’s just weird.
I need sustenance. Hold on. OK, back with banana and chai. I should be fine soon.
OK, how can we do this? We can’t let game import main, because main needs to import game.Game if nothing else. So what if we move anything that game needs over to the game file and allow main to import it from there as long as it needs to?
Or, we could just move everything that’s at top level over to game all at once. Let’s try that and see if everything could just work.
OK, with a little editing of tests to refer to game
instead of main
, I’m green with everything moved over to game.py. However, that’s going to be an issue. What will we call the Game instance? We’ve used up lower and upper case “game” already.
To make the initial test_game test run, I’ve added an empty class to the game file:
class Game:
def __init__(self):
pass
Let’s see about this. We’ll create a game instance in main and call its main_loop
. Then we’ll make that work ad then if it seems likely that we can continue, we can safely commit. For now, we’re still experimenting.
from game import main_loop, Game
asteroids_game: Game
if __name__ == "__main__":
asteroids_game = Game()
asteroids_game.main_loop()
There is no main_loop in game … yet. Build one:
class Game:
def __init__(self):
pass
def main_loop(self):
main_loop()
The tests don’t care. Will the game run? Yes, it will. We could commit but not yet. I’m still not dead certain that this will work, and I don’t want to commit to it until I’m sure it’s a decent path.
Let’s bring main_loop
into Game class. I’ll try Refactor-Inline.
“Cannot inline functions with global variables.” Yeah, it does that:
def main_loop():
global running, ship, clock, delta_time, game_over_surface, game_over_pos
game_init()
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
check_ship_spawn(ship, ships, delta_time)
check_next_wave(delta_time)
control_ship(ship, delta_time)
for missile in missiles.copy():
missile.update(missiles, delta_time)
move_everything(delta_time)
check_collisions()
draw_everything()
if game_over: draw_game_over()
pygame.display.flip()
delta_time = clock.tick(60) / 1000
pygame.quit()
I’m not convinced that it really needed to declare all those at the top. But we need to figure out an approach. In the end, we want all the module-level variables to be members of the class. Or, if there are exceptions, I don’t know what they are. We can live with them as globals for a while, because we already are living that way. This is one of those tree things. The best time to plant a tree is twenty years ago. The best time to have made this a class was about 35 articles ago.
I’ll copy the function down and manually make it compile. That works and I cam remove the main_loop
top level function. Now I’m convinced that this scheme is probably viable. I say probably. I’m quite sure that modulo some hackery, it will work. I think it’s going to be quite tedious unless I can get smarter. But we can commit: New game object used in main and executes the main_loop.
Let’s Reflect
OK, where are we? We have a game.py with a Game class, and main creates an instance asteroid_game
and calls its main loop. Everything else follows from that.
There are surely many tests looking at various globals that are now over in game. Let’s take a look at how many there are: I just search for “game.” and it looks like less than twenty, maybe around ten. We’ll be able to cope.
Copying the main loop as a method was easy, as it includes whatever global
declarations it needs. I think that means that I can move methods in, one at a time, and leave all the global variables outside until all the methods are folded in.
Let’s proceed down that path until we learn something.
Folding In
First thing the main loop does is game init. I’ll move that in. I just cut, paste, and add self
as a parameter. The tests and game run. Commit: game_init method added to Game.
def game_init(self):
global screen, clock, running
pygame.init()
screen = pygame.display.set_mode((u.SCREEN_SIZE, u.SCREEN_SIZE))
pygame.display.set_caption("Asteroids")
clock = pygame.time.Clock()
define_game_over()
define_score()
running = True
insert_quarter(0)
Let’s bring in the functions called above, one at a time of course.
The two defines go fine. MOving inert_quarter
does not, because it’s called from control_ship
and that global function has no access to a game instance. I’m tempted to introduce a singleton, but that would be bad.
I do have a game instance over in main. We could send the official instance over to the Game object and cache it. I think the real issue is larger, that the game probably shouldn’t have the control_ship function at all, but I’m not sure where it should be or how to do it. I need a smarter pair than the cat. Or a more communicative one.
Let’s do that, like this:
if __name__ == "__main__":
asteroids_game = Game()
asteroids_game.set_instance(asteroids_game)
asteroids_game.main_loop()
And
def set_instance(self, a_game):
global current_instance
current_instance = a_game
And control_ship
uses current_instance
to call insert_quarter
.
Game works, tests green. Commit: moving functions into Game class.
Let’s Reflect
This seems to be going fairly smoothly. We are leaving all the globals as global but I think that will be OK, they’re all in this module, so they aren’t as global as they might be.
The necessity to have the current instance is unfortunate but I think it’s an interim change as well. We’ve unwound the functions that game_init
calls. Let’s see about any module-level references it makes:
def game_init(self):
global screen, clock, running
pygame.init()
screen = pygame.display.set_mode((u.SCREEN_SIZE, u.SCREEN_SIZE))
pygame.display.set_caption("Asteroids")
clock = pygame.time.Clock()
self.define_game_over()
self.define_score()
running = True
self.insert_quarter(0)
Moving clock
into the class looks possible. Let’s try it.
Easy. I move the clock creation to init and refer to self in the main loop:
def __init__(self):
self.clock = pygame.time.Clock()
def main_loop(self):
...
pygame.display.flip()
delta_time = self.clock.tick(60) / 1000
pygame.quit()
That works. Commit: move clock into game instance.
Let’s do delta_time, that should be easy enough. I define it, remove the global, find references, put self in front of them. I think we’re OK.
Game works, tests are green. I’m uncertain, one more search for “delta_time”. Looks good. Something I don’t like. The member is named delta_time
and it is passed as a parameter to a lot of functions, all of which receive it as delta_time
, which makes it a bit difficult to see whether a given reference to delta_time
needs to add self.
. Must think about that.
We’re green and game works. Commit: move delta_time into game instance.
Reflection
I’ve been at this for about 2 1/2 hours with breaks for chai and such. I think it’s going well, but it is certainly tedious. There’s no question in my mind that the scheme will work, and that I can continue by moving as little as one function or one variable at a time. It would be nice to find a faster, more automatic way to do it. I’ll think on that. For now, I think we’ll call it a morning and have a rest.
Summary
The good news, and it is important, is that we found a decent way to move game functionality from top-level functions into a class.
Moving to a class sooner would have been better. Having a starting template that started with a class would be enough, and I’ll make a Jira note to do that. But this one thing at a time approach will work well enough, even if I don’t get a more efficient idea. And if you have a more efficient idea please toot or tweet me up.
See you next time!
Jira
- Smart collections?
- Game object
- Main loop
- Globals etc
- Hyperspace
- Saucer
- Available Ship Display tuning
- Make PyGame template with Game class
-
The existence of half-baked ideas clearly implies the existence of p-baked ideas for all p between zero and one. ↩