Python 201 - A Nice Idea
Python Asteroids+Invaders on GitHub
Bruce gave me a very nice idea: Bumpers! I think I’ll try it.
Bruce Onder1 sent me this note on Mastodon:
WRT the left and right columns - what if you implement invisible bumpers and detect collisions on them with the invaders?
This is, of course, brilliant. The fundamental notion of the “decentralized” design we’re using here is that there are these objects in the mix and they interact and decide what to do based on what they interact with. So a bullet interacts with a ship, and if they’re close enough together, the ship and bullet destroy themselves.
Naively, I was thinking that I’d have to have some logic in my InvaderFleet to check the coordinates of the outermost invaders and reverse their travel if they had gone far enough. I mentioned yesterday something about how that might be done. Bruce’s idea is much more i line with the overall design. To repeat it with a bit more detail:
- Put two invisible vertical rectangles where the boundaries should be;
- Let them interact with all the invaders;
- If an invader hits a rectangle, tell the InvaderFleet to reverse;
- When the movement cycle is over, InvaderFleet checks the flag and adjusts the movement.
So fine. I like it. I’m glad Bruce thought of it, because I don’t think I would have. Here again we see the value of collaboration vs working alone.
So let’s get started doing that. We’re a ways away from it, but maybe not as far as it looks.
All we have right now is our fixed display of dots:
As a first step, I want to put a yellow square behind the dots. The reason is that I want to check out my understanding of how pygame rectangles work.
class Invader:
def __init__(self, x, y):
self.x = x
self.y = y
def draw(self, screen, start, step):
pos = (start.x+self.x*step, start.y - step*self.y)
rect = pygame.Rect(0, 0, 32, 32)
rect.center=pos
pygame.draw.rect(screen, "yellow", rect)
pygame.draw.circle(screen, "red", pos, 16)
Note that we’ve defined the rectangle to be top-left at 0, 0 and size 32, 32. But then we set its center to be the same as the position of our dot. We get a nice yellow background behind our red dots:
You may be wondering why I did that. The reason is that we’ll need our invaders to have a rectangle to check for collision with our bumpers, and I wanted to see whether I could create that rectangle in that simple center-oriented way or whether I’d have to compute an offset and such. In the past, I’ve done it with offsets, because I didn’t know about that aspect of Rect.
Now, let’s create a Bumper and an Invader and see if we can make them detect a collision.
I should mention that if this scheme seems kind of “indirect”, that is intentional. It’s part of this design scheme that the logic of the game is down in the individual objects. It would be OK for InvaderFleet to do logic, but our design considers it “better” to defer the decisions down to individual objects. Our Invaders are not full-fledged Flyer subclasses, at least not now, but in principle they could be, and we want to treat them as if they were.
Here’s a sketch test:
def test_bumper_invader_collision(self):
fleet = InvaderFleet()
bumper = Bumper(16)
invader = Invader(5, 2)
start = Vector2(64, 512)
step = 64
invader.draw(None, start, step)
invader.interact_with_bumper(bumper)
assert not fleet.reverse
We have a number of false assumptions in here:
- There is no Bumper class. I’m supposing the input is its x coordinate;
- Invader expects a surface to draw on and I’m passing None. My plan is that Invader will check its surface and not draw if there is None, allowing this test to run.
- Invader doesn’t know
interact_with_bumper
yet. - InvaderFleet doesn’t have
reverse
.
We’ll tick through those. I create the Bumper as a Flyer and let PyCharm give it all the abstract methods. I think that’s going to be the most irritating part of this implementation. Perhaps we should have two different Flyer subclasses, AsteroidsFlyer and InvadersFlyer. (And there might be some objects that are there for both and inherit from Flyer.) That sounds like a good idea. I’ll probably do it but for now we’ll move on.
I’m planning that the Bumper will be outside the InvaderFleet, so it will not need to interact with Invader but it will need to ignore InvaderFleet. I include a pass
method for that.
Change Invader not to draw if screen is None:
class Invader:
def __init__(self, x, y):
self.x = x
self.y = y
self.rect = pygame.Rect(0, 0, 32, 32)
def draw(self, screen, start, step):
pos = (start.x+self.x*step, start.y - step*self.y)
self.rect.center = pos
if screen:
pygame.draw.rect(screen, "yellow", self.rect)
pygame.draw.circle(screen, "red", pos, 16)
I’ve promoted the rectangle to a member variable. What I’m up to is that the Invader will maintain her rectangle whenever she moves. But she doesn’t move yet, so I’m maintaining it in draw
for now. We’ll inch it over soon.
My test should be executing and failing now, if I would be kind enough to import Bumper into it.
Darn. PyCharm wanted to restart and now I’ve lost my pinned test runs. Hold on, I’ll put them back. Little interruptions like this derail one’s thoughts. I’ll try to find the rails soon.
Hm a bunch fail. Whazzup with that? Oh the darn checkers for missing methods again. I’m going to disable those but I need to clean them up because they do have a bit of value.
@pytest.mark.skip("needs updating")
Nice, and I even get an ignored
count when they run.
OK, now I’m getting what I expected:
> invader.interact_with_bumper(bumper)
E AttributeError: 'Invader' object has no attribute 'interact_with_bumper'
And we can do that.
class Invader:
def interact_with_bumper(self, bumper, invader_fleet):
if bumper.rect.colliderect(self.rect):
invader_fleet.at_edge()
class InvaderFleet(Flyer):
def __init__(self):
self.invaders = [Invader(x//5, x % 5) for x in range(55)]
self.reverse = False
def at_edge(self):
self.reverse = True
I think the test might be green now. Well it would be if it were correctly written.
def test_bumper_invader_collision(self):
fleet = InvaderFleet()
bumper = Bumper(16)
invader = Invader(5, 2)
start = Vector2(64, 512)
step = 64
invader.draw(None, start, step)
invader.interact_with_bumper(bumper, fleet)
assert not fleet.reverse
Now to get an Invader to collide. Ideally, this one. I’ll have to do some arithmetic.
We want start such that Invader(5, 2) has his rectangle intersecting with (16,0 17,1024), and he sets his position to
pos = (start.x + step*self.x, start.y - step*self.y)
So we want start.x + 645 = 16, so start.x = 16 - 645 = -304. Maybe.
def test_bumper_invader_collision(self):
fleet = InvaderFleet()
bumper = Bumper(16)
invader = Invader(5, 2)
start = Vector2(64, 512)
step = 64
invader.draw(None, start, step)
invader.interact_with_bumper(bumper, fleet)
assert not fleet.reverse
start = Vector2(-304, 512)
invader.draw(None, start, step)
invader.interact_with_bumper(bumper, fleet)
assert fleet.reverse
This passes. Let’s make it a bit more clear where that -304 came from:
def test_bumper_invader_collision(self):
fleet = InvaderFleet()
bumper_x = 16
bumper = Bumper(bumper_x)
invader_column = 5
invader = Invader(invader_column, 2)
start = Vector2(64, 512)
step = 64
invader.draw(None, start, step)
invader.interact_with_bumper(bumper, fleet)
assert not fleet.reverse
start_x = bumper_x - invader_column*step
start = Vector2(start_x, 512)
invader.draw(None, start, step)
invader.interact_with_bumper(bumper, fleet)
assert fleet.reverse
Better. Also green. Commit: InvaderFleet and Invader begin to use Bumper to detect edges.
We’ll set that aside and work on movement. The game cycle, managed in Fleets, is update, interactions, tick, draw. We want to move our invaders on update.
I guess we should test-drive this.
Before I get started, I want to change Invader. We’re using x and y in there and they are really column and row. We’ll want to keep those notions distinct. That’s done. We’ll see that in a moment.
Now how do we want InvaderFleet to move the invaders? Given the bumpers, I think it will just have a point (Vector2) that it will increment, and tell all its invaders to move_relative to that point. The (0, 0) invader will move right there, etc.
Something like this:
Arrgh. Pygame has bitten me hard when I renamed those variables. Roll back. Do the change locally. Commit: rename invader members x,y to row,column.
Back to moving. Let’s work on InvaderFleet, showing that it has and can maintain its origin point.
def test_fleet_origin(self):
fleet = InvaderFleet()
assert fleet.origin == Vector2(u.SCREEN_SIZE / 2 - 5*64, 512)
Lots of magic there and in fact it’s copied from what I just did in InvaderFleet:
class InvaderFleet(Flyer):
def __init__(self):
self.invaders = [Invader(x//5, x % 5) for x in range(55)]
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, 512)
self.reverse = False
And I think it would be wise to move the invaders during init. I’ll extend my test:
def test_fleet_origin(self):
fleet = InvaderFleet()
assert fleet.origin == Vector2(u.SCREEN_SIZE / 2 - 5*64, 512)
invader = fleet.invaders[5*5] # bottom row middle column
assert invader.rect.center[0] == 512
Nice (not nice) of pygame to return a tuple for center instead of a Vector2. We’ll deal with it. Test fails for now, because the center hasn’t been set.
class InvaderFleet(Flyer):
def __init__(self):
self.invaders = [Invader(x//5, x % 5) for x in range(55)]
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, 512)
self.reverse = False
self.update(0, None)
def update(self, delta_time, _fleets):
for invader in self.invaders:
invader.move_relative(self.origin)
class Invader:
def move_relative(self, origin):
pos = (origin.x + 64*self.row, origin.y - 64*self.column)
self.rect.center = pos
If I were to pass in a Vector2, would the center come out as a Vector2? No.
Somehow I have broken the interaction test.
def test_bumper_invader_collision(self):
fleet = InvaderFleet()
bumper_x = 16
bumper = Bumper(bumper_x)
invader_column = 5
invader = Invader(invader_column, 2)
start = Vector2(64, 512)
step = 64
invader.draw(None, start, step)
invader.interact_with_bumper(bumper, fleet)
assert not fleet.reverse
start_x = bumper_x - invader_column*step
start = Vector2(start_x, 512)
invader.draw(None, start, step)
invader.interact_with_bumper(bumper, fleet)
assert fleet.reverse
The first assert is failing, saying that reverse
has been set. I am tiring and need a break. Fix this test and then break?
Ah. We have to initialize our invader better, I think. He doesn’t update on draw any more.
def test_bumper_invader_collision(self):
fleet = InvaderFleet()
bumper_x = 16
bumper = Bumper(bumper_x)
invader_column = 5
invader = Invader(invader_column, 2)
start = Vector2(64, 512)
step = 64
invader.move_relative(fleet.origin)
invader.interact_with_bumper(bumper, fleet)
assert not fleet.reverse
start_x = bumper_x - invader_column*step
start = Vector2(start_x, 512)
invader.move_relative(start)
invader.interact_with_bumper(bumper, fleet)
assert fleet.reverse
We are green and our changes are harmless. The invaders still draw correctly. Let’s do one more thing. We no longer use the start and step in Invader.draw
so let’s remove them.
class InvaderFleet(Flyer):
def draw(self, screen):
pos = u.CENTER
hw = Vector2(100, 200)
rect = (pos - hw/2, hw)
pygame.draw.rect(screen, "blue", rect)
step = 64
for invader in self.invaders:
invader.draw(screen)
class Invader:
def draw(self, screen):
if screen:
pygame.draw.rect(screen, "yellow", self.rect)
pygame.draw.circle(screen, "red", self.rect.center, 16)
All still good. Commit: steps toward invader motion. Let’s sum up and break before we do any damage.
Summary
We have moved closer to moving the invaders. They now understand a method move_relative(origin)
, which sets their rectangle center to the proper position relative to the origin point. The origin point is the proposed position for the (0, 0) invader.
In addition, the invaders can determine whether they intersect a Bumper, a new class (thanks, Bruce!) that we’ll use as a boundary on each side of the screen. When an invader hits the Bumper, it will send a message at_edge()
to the InvaderFleet, which will then know to turn the invaders in the other direction … once the current move cycle is complete.
We haven’t quite got it all in place. I had expected a bit more this morning, but I’ve been at it for two hours and it’s time for a break. It’s also 0812 hours. I got up early.
It’s a bit ragged right now, but I think the basics are taking shape. I confess that I did patch in a simple move in InvaderFleet update
and the red dots do move as one would expect. So I know we’re pretty close.
But I’m tired and further work will be more ragged and likely to break something. I’ll pick it up next time, perhaps this afternoon.
See you then!
-
@bonder@tilde.zone. I always think of him as W. B. Onder, and I bet I’m just one of many who have irritated him by saying that. Thanks, Bruce! ↩