Python 196 - Invaders
Not Yet: Python Space Invaders on GitHub
Today I plan to set up a new Python project. This one will be a Space Invaders replica. I’ll say a bit about the idea, but this is mostly just a record of how setup went.
Intro
I have polished Asteroids with about 10000 grit and there isn’t much of interest left to do. I don’t want to hassle myself, so I am ignoring suggestions that I should convert it to an iOS or Android app, or anything else that would put me at the mercy of someone else’s rules and software.
And I really have enjoyed discovering how to do Asteroids in Python/Pygame, and discovering how much meat there is for learning and testing and refactoring even in a small program like that one.
So I’m going to do Space Invaders, which I have done once before in Codea Lua. I will probably steal the assets from that program but otherwise pay no attention to it. As with Asteroids, I will try to come close to the original game, including the terrible graphics, and I’ll study the original source, if I can find it again, for details.
We’ll have more intro next time. This is enough to tell you what I’m up to. What follows is a more or less contemporaneous record of what I had to do to get set up. You might skim it, but there’s nothing really exciting there, it’s just enough notes so that when I do this yet again I’ll have something to refer to.
Setting up the new project
- specify directory, just fill in “invaders”;
- New environment using Virtualenv;
- accept location;
- accept interpreter 3.11;
- uncheck create main.py welcome script;
- click create;
PyCharm opens new project in new window. Does not ask about closing the old one, as the page suggests it will.
I wish I had asked for a welcome script.
I think I’ll just create a test, test_hookup.py. Type “import pytest”. PyCharm objects, then offers to install pytest and does so.
import pytest
class TestHookup:
def test_hookup(self):
assert 3== 2 + 2
Up top, Run only offers “Current File” and a config option. I think I’m supposed to configure something here. If I do run the file, the test fails. Fix it. 4 == 2 + 2
. It runs green. Nearly good.
Then I copy-pasted main.py from Asteroids, and made a minimal Game file, see below.
Then I made a couple of test files. Run them by clicking the > in the file. This seems to be important to what follows.
Then in the Run pull-down, Edit Configurations. Under PythonTests, configurations for those individual tests will be there.
Click one to change. Select (*) Script Path. Click Folder at right. Select “invaders” folder. Click OK. Now should find, in the run pull down, “pytest in invaders”, which will thereafter run all the tests.
I think we are configured. Our code as of now:
main.py
# Invaders Main program
from game import Game
invaders: Game
if __name__ == "__main__":
invaders = Game()
invaders.main_loop()
game.py
# Invaders Game
class Game:
pass
def main_loop(self):
return "done"
test_hookup.py
import pytest
class TestHookup:
def test_hookup(self):
assert 4 == 2 + 2
test_game.py
from game import Game
class TestGame:
def test_main_loop(self):
game = Game()
assert game.main_loop() == "done"
Now let’s create a game.
Reference pygame for first time. PyCharm offers install and import. Nice.
Time passes …
I then wasted quite a bit of time trying to open a trivial window with no window appearing but the main loop obviously running. It turns out that you have to run the event loop for pygame to display a window.
In almost no time at all, namely about an hour, mostly spent not knowing that you have to get the events to have a window show up, I finally have this exciting Game running:
class Game:
def __init__(self):
pygame.init()
pygame.display.set_caption("Space Invaders")
self.delta_time = 0
self.clock = pygame.time.Clock()
self.screen = pygame.display.set_mode((256, 256))
self.player_location = Vector2(128, 128)
def main_loop(self):
running = True
while running:
# Look at every event in the queue
for event in pygame.event.get():
# Did the user hit a key?
if event.type == KEYDOWN:
# Was it the Escape key? If so, stop the loop.
if event.key == K_ESCAPE:
running = False
# Did the user click the window close button? If so, stop the loop.
elif event.type == QUIT:
running = False
self.screen.fill("midnightblue")
rect = Rect(0, 0, 32, 32)
rect.center = self.player_location
pygame.draw.rect(self.screen, "white", rect)
pygame.draw.line(self.screen, "red", (128, 0), (128, 256))
pygame.draw.line(self.screen, "red", (0, 128), (256, 128))
pygame.display.flip()
self.delta_time = self.clock.tick(60) / 1000
That produces this fine window:
I think my tests will fail because I’m not returning “done” any more. Worse, however, is that they don’t run until I close the window. Now I know why I had that testing flag in Asteroids.
I add the return "done"
, and let’s set a testing flag much as we did in Asteroids, so that we don’t init pygame and don’t run the main loop:
class TestGame:
def test_main_loop(self):
game = Game(testing=True)
assert game.main_loop() == "done"
class Game:
def __init__(self, testing=False):
self._testing = testing
if not testing:
pygame.init()
pygame.display.set_caption("Space Invaders")
self.delta_time = 0
self.clock = pygame.time.Clock()
self.screen = pygame.display.set_mode((256, 256))
self.player_location = Vector2(128, 128)
def main_loop(self):
running = not self._testing
while running:
for event in pygame.event.get():
if event.type == KEYDOWN:
if event.key == K_ESCAPE:
running = False
elif event.type == QUIT:
running = False
self.screen.fill("midnightblue")
rect = Rect(0, 0, 32, 32)
rect.center = self.player_location
pygame.draw.rect(self.screen, "white", rect)
pygame.draw.line(self.screen, "red", (128, 0), (128, 256))
pygame.draw.line(self.screen, "red", (0, 128), (256, 128))
pygame.display.flip()
self.delta_time = self.clock.tick(60) / 1000
return "done"
Now the tests run and so does the “game”.
Let’s sum up.
Summary
As initial project setups go, this one was pretty smooth. I looked at PyCharm’s instructions for an initial project, and at the PyCharm/pytest documentation page, and at a few pages in Pygame, mostly trying to figure out why my window wasn’t initially coming up. That was because you have to do the event.get()
stuff. Who knew?
Of note is the setup for the square that I drew:
self.screen = pygame.display.set_mode((256, 256))
self.player_location = Vector2(128, 128)
...
rect = Rect(0, 0, 32, 32)
rect.center = self.player_location
pygame.draw.rect(self.screen, "white", rect)
The rect.center
setting causes the rectangle to be drawn with its center at player_location
rather than at (0, 0) as the rectangle definition would suggest. I had recently read something about that. In previous code I have mostly been adjusting manually for the center of a rectangle that I was blitting. This will allow me to work with centers and avoid the adjustment.
Of course, the Asteroids program no longer blits. But I think that in Space Invaders we will wind up using the blit feature. Anyway, I just wanted to try center
.
Our Game test shouldn’t really be calling main_loop
over the longer term, because there will be all kinds of pygame stuff going on in there, but we’ll leave it for now.
What we have isn’t much, but it is a running Python / Pygame app named Space Invaders, set up with tests and one real class. It’s not well organized, it’s woefully incomplete. And we can show it to our customers (me) and honestly say “We’re on the way, and it’s only 0830 on the day we started!”
See you next time!