Python 190 - Let's try something
Now that we’ve improved SurfaceMaker, can we improve it all the way to nothing? Let’s find out.
As I’ve spoken about before, in a production system, we might not make all the changes that we make here. A large part of what we’re doing here is exploring better ways to program, and ways of changing our programs without breaking them. There is, however, one very good reason for making some improvements of the kind we do here: it helps us go faster, for at least two reasons:
First, if we improve the code in the areas we change for other reasons, typically adding features, we make adding more features faster, because the code becomes easier to work with and tends to stay easy to work with.
Second, we become better at doing our work, and thus are able to complete good work sooner.
As you read these many(!) articles, I hope you’ll notice how simple most improvements are, and how they do make the code easier to work with. I hope you’ll get ideas for some simple changes to how you work that will make your work easier.
Today, I want to try something that might make the program simpler. I’m not certain that it will, but I think it’s worth a try. My plan is to draw an asteroid directly onto the screen rather than save it in a surface and blit the surface. I plan to do this as a series of experiments, and let the results guide what I do next, which might include giving up the idea, or pushing all the way to removing all the SurfaceMaker surfaces.
Let’s get to it. Here’s Asteroid’s draw
:
class Asteroid(Flyer):
def draw(self, screen):
top_left_corner = self.position - self._offset
screen.blit(self._surface, top_left_corner)
Suppose asteroids were circles. Let’s start by adding a circle to this display.
As soon as I do this, I notice that something is wrong: the circle isn’t centered on the asteroid:
def draw(self, screen):
top_left_corner = self.position - self._offset
screen.blit(self._surface, top_left_corner)
pygame.draw.circle(screen, "white", self.position, self.radius, 3)
Why? Because we set the _offset
like this:
self._offset = Vector2(self.radius, self.radius)
This bug has been in the game forever. The asteroid offset does not account for the extra size of the surface due to fat lines. We need to calculate offset like this:
self._surface = SurfaceMaker.asteroid_surface(asteroid_size)
w,h = self._surface.get_size()
self._offset = Vector2(w/2, h/2)
This fixes that bug:
Comment out the circle, commit: fix long-standing small offset error in asteroids drawing.
- Observation
- One somewhat nice thing about working to keep the code alive is that from time to time we find small errors and can fix them. This error would be almost impossible to notice in play, but once in a while we might see an asteroid destroyed when it looked like we would miss, or survive when it looked like we hit it.
Now that the offset is fixed, it’s also clear that a circle of radius self.radius
is larger than the picture of the asteroid. Why is that? I would like to know.
- Observation
- We see again that our starting idea, drawing asteroids differently, has allowed us to observe something we had never observed before. We could ignore it, but I think not. If the radius of the asteroid is larger than the drawing, we’ll see the asteroid exploding before the missile gets there, and we might see it exploding when it looks like the missile will miss. And, if I look carefully as I play, I do see explosions happening early. Let’s explore what’s going on.
First, let’s work through the __init__
to see what’s going into SurfaceMaker.
class Asteroid(Flyer):
def __init__(self, size=2, position=None):
self.size = max(0, min(size, 2))
self._score = u.ASTEROID_SCORE_LIST[self.size]
self.radius = [16, 32, 64][self.size] * u.SCALE_FACTOR
position = position if position is not None else Vector2(0, random.randrange(0, u.SCREEN_SIZE))
angle_of_travel = random.randint(0, 360)
velocity = u.ASTEROID_SPEED.rotate(angle_of_travel)
self._location = MovableLocation(position, velocity)
scaled_size = u.SCALE_FACTOR * self.radius * 2
asteroid_size = Vector2(scaled_size, scaled_size)
self._surface = SurfaceMaker.asteroid_surface(asteroid_size)
w,h = self._surface.get_size()
self._offset = Vector2(w/2, h/2)
For a maximum asteroid, input size is 2. And radius will be set to 64 times the universe scale factor, which is 0.75, so radius will be 48 if I’m not mistaken.
Then, get this, scaled_size will be set to radius times two times the scale factor again! That can’t be right.
Let me try removing that second multiplication by scale factor. Then, with a tweak to make the circle green, it looks like this:
That looks better. After a bit of thinking and checking the code, I believe we have a little bit of a fence-post problem1 here. The asteroid’s raw coordinates span from -4 to 4. Adjusted, they span from 0 to 8. A point at zero and a point at 8 won’t fit into a surface of size 8: the point at 8 will be off the edge.
But our adjustment vector of (2, 2) is correcting for that issue. And if what I’m here to try today works, it won’t be an issue any more.
I want to draw my asteroid lines directly on the screen. Let’s do it this way:
def draw(self, screen):
raw = raw_rocks[0]
scaled = [point * self.radius / 4 for point in raw]
moved = [point + self.position for point in scaled]
pygame.draw.lines(screen, "white", False, moved, 3 )
I make them all the same shape, raw_rocks[0]
. The raw radius is 4, so the scale should be my radius / 4. The raw shape is already centered, so after scaling I can just add my position and draw the lines. It looks like this:
The game plays just fine, it looks just fine, and I consider that this little experiment has shown a good result: We can draw things directly on the screen and avoid using SurfaceMaker at all. I do think we need something a bit better than the code above, however.
Let’s think about that and then, very likely, stop here for the morning, with an idea as to how to proceed.
Roll back this spike. We’re back to using the bitmap surfaces, with the offset fixed.
- Speculation
- We’ll think now about how we might go about implementing all the flyer drawing using direct calls to draw on the screen. This is speculative. “Speculative” is a long word meaning “probably wrong”. We might sketch some code below, or we might not. How could I know? We’re not there yet.
The code above scales and moves the points on every draw. The move is necessary, because the asteroid position changes all the time. We could do the scaling once and for all when we decide what the asteroids shape and size will be.
Naively, and that’s not necessarily a bad word, we could set up our size / radius info and then select a random rock and scale it, saving it as a member of Asteroid. If we do that, every asteroid will have a list of however many points, even if it has the same shape as another asteroid. Since there are only four shapes, that seems a bit wasteful.
What about rotations though? Do we rotate the asteroids randomly when we create them? Apparently we do not: that must have been some other Asteroids that I wrote.
The complete adjustment from raw to ready can be written in a one liner, like this:
ready = [(point * scale) + position for point in raw]
If we had already done the scaling, it would still be
ready = [point + position for point in scaled_raw]
So the cost of not making a scaled copy is a multiply in the draw and our spike already did that and in fact processed the list twice. So we should just select the raw list and keep that pointer in each asteroid. That won’t copy the list, so we won’t be adding a lot of memory.
It might make sense, though, to have a little object wrapping the raw rock definition, which I”ll sketch like this:
class SketchAdjuster:
def __init__(self, raw_rock_list):
self._list = raw_rock_list
def adjusted(self, scale, position):
return [(point * scale) + position for point in self._list]
Or maybe we even put draw in there:
class AdjustedListDrawingThing:
def __init__(self, raw_rock_list, scale):
self._list = raw_rock_list
self._scale = scale
def _adjusted(self, position):
return [(point * self._scale) + position for point in self._list]
def draw(screen, position):
pygame.draw.lines(screen, "white", False, self._adjusted(position), 3)
Something like that.
Let’s go seriously blue-sky for a moment. I’m used to having a screen that can translate and scale for me. That would let me write something like this:
class Asteroid:
def draw(self, screen):
screen.push()
screen.translate(self.position)
screen.scale(self.scale)
screen.draw_lines(self.points)
screen.pop()
It seems to me that we could pretty readily create such a thing if we wanted to. It wouldn’t be totally general but it would be general enough to do the job we need. Internally, instead of moving and scaling the screen (which is never what we really do, we just pretend that’s what we’re doing), our new screen thing would remember the transformations and apply them to the input points just like the code above.
We wouldn’t do the whole matrix thing. That would be overkill for our purposes here. And we might just have translate
and scale
override any prior values, rather than apply on top of them as a real screen transformation system would.
I think this is a promising idea. Further into the blue sky …
Our objects all consist of lists of points to be connected by lines, with one exception, the drawing of the astronaut, which includes a circle and some lines. But I think we already draw those directly on the screen, don’t we? Yes. Fragments consist of commands:
class CircleCommand:
def __init__(self, position, radius, width):
self._pos = position
self._radius = radius
self._width = width
def draw(self, screen, position, theta):
head_off = self._pos.rotate(theta)
pygame.draw.circle(screen, "white", position + head_off, self._radius, self._width)
I wonder whether we could leverage the fragment code to draw everything. We’d just need one more command, lines
. Then an asteroid would be a Fragment consisting of a single lines
command.
That’s also interesting. But fragments don’t scale and they move on their own.
Back closer to earth …
It’s probably better to work separately on our new direct-to-screen asteroids, saucer, and ship. Then once we have them in place, or at least further along than the current pure speculation, we could look for commonality with Fragment and deal with facts rather than fancy.
I am far more comfortable thinking about a new object than I am about bashing an existing object into a very different shape. The thing we’re thinking about here scales and translates, and Fragments don’t do that. They don’t seem like a good fit, but when we have both cases in hand, there might be some merging to be done.
Enough speculation. Let’s sum up.
Summary
An experiment found a long-standing bug where asteroids were a pixel or two off center. I had never noticed that in the game, but it did mean that some hits on the asteroid came earlier than you’d expect, and probably some misses were possible that looked like hits.
It often happens when we inspect “working” code that we discover things like this, especially in code that basically can’t be tested, like graphical displays. So I am happy to have found the defect and don’t feel terribly bad about it being there. It seems to have done no real harm.
Then another quick experiment showed that it is easy to draw a scaled and repositioned asteroid where it needs to be. That opens the door to drawing our objects more directly from their list of points, and that can lead to removing SurfaceMaker, which will definitely remove some complexity from the system.
Was SurfaceMaker a bad design decision? Well here in July 2023, it appears that we can do better. But that’s because it’s July 2023 and we know more than we knew in April 2023, when we had only just discovered Pygame. So it may have been a good decision then. It was certainly good enough, and for that matter, it’s good enough now.
It’s just that it can be better. We don’t always make every change that makes the program better, and you could make a case that this program is done. But in our real programming lives, it seems that programs get maintained and enhanced for ages, and in that case, a design simplification like this one can really pay off. No more wondering about SurfaceMaker. Just make a list of points, centered around your position, and there you go.
I think we’ll push this idea further, all the way to the removal of SurfaceMaker. I am confident that we can do it, because all our objects are just line drawings.
I look forward to seeing this happen!
See you next time!
-
If we want 80 feet of fence with posts five feet apart, how many posts do we need? Well, 80 over 5 is 16, so we need 16 posts, right? We’d better think again. Let’s make it simpler. If we want five feet of fence with posts five feet apart, how many posts do we need? 5 over 5 is 1, so we need one post, right? No, two, one at each end. Posts = length over distance plus 1. Pixels work the same way. ↩