Python 186 - Too Big!
The screen objects are too big for the amount of screen space we have. We need a global change of some kind! (Turns out to be disappointingly easy.)
Here’s our game:
Here’s a screen shot of the classic game:
When I originally estimated the size of our objects, I was thinking that we’d run on a 2048x2048 screen. But somewhere inside, pygame is drawing our screen at a sort of double scale. We init the screen to 1024x1024, but it is drawn 2048x2048. I have not been able to figure out how to change that. There seems to be no live forum for pygame, or other information source.
I think I have picked a dead library. Bummer. If you know something about this, please toot or tweet me up.
We have to fix this problem. We’ll assume there is no way to tell pygame to use the native resolution of the screen, so our only recourse is to scale everything down by about 1/2. We’d like to do that without changing all the display code everywhere, but that may turn out to be necessary.
First let’s experiment. We’ll start with ship for no particular reason.
We see something interesting:
def __init__(self, position, drop_in=2):
self.radius = 25
self._accelerating = False
self._acceleration = u.SHIP_ACCELERATION
self._allow_freebie = True
self._angle = 0
self._asteroid_tally = 0
self._can_fire = True
self._drop_in = drop_in
self._hyperspace_generator = HyperspaceGenerator(self)
self._hyperspace_timer = Timer(u.SHIP_HYPERSPACE_RECHARGE_TIME)
self._location = MovableLocation(position, Vector2(0, 0))
self._missile_tally = 0
self._shipmaker = None
ship_scale = 4
ship_size = Vector2(14, 8)*ship_scale
self._ship_surface, self._ship_accelerating_surface = SurfaceMaker.ship_surfaces(ship_size)
The ship_scale
value is interesting, and it turns out that if we set it to 2 instead of four, the ship gets smaller.
So that’s interesting. We would need to change the ship’s radius up above, or it would explode before things visibly hit it. Let’s look at the surface maker while we’re here.
Hell! Look at this:
class SurfaceMaker:
@staticmethod
def ship_surfaces(ship_size):
raw_points_span = Vector2(14, 8)
raw_points_offset = raw_points_span / 2
scale_factor = ship_size.x / raw_points_span.x
ship_surface = SurfaceMaker.create_scaled_surface(
ship_size, raw_points_offset, scale_factor, raw_ship_points)
accelerating_surface = SurfaceMaker.create_scaled_surface(
ship_size, raw_points_offset, scale_factor, raw_ship_points, raw_flare_points)
return ship_surface, accelerating_surface
Do you see that scale_factor
? We have similar code in the creation of all the objects, ship, saucer, and asteroid. If we change that scale factor, it will change the size of the surface we create and our job is nearly done.
We do see that there is scaling thinking being done both up there in Ship.__init__
and down here in SurfaceMaker, but at base, if we scale that scale_factor
in SurfaceMaker, things will change size.
I can do a quick test of that, by just dividing all the scale_factor
occurrences by 2, and I get this:
Now this doesn’t resolve the whole issue but it makes it far easier than I expected, so easy that I hesitate to continue writing about it. There are further matters to deal with, however:
-
I noticed when turning the ship that it is not rotating around its center any more. There’s some assumption between Ship and SurfaceMaker that is not reflecting my patch.
-
We’ll need to change all the hit radii to match the scale factor as well.
-
The ScoreKeeper needs to adjust the free ships layout to match the scale.
The easy solution to all these may be to put this final scale factor at a high level, probably in u.py
and apply it where needed.
But that’s not entirely satisfying. As things stand now, the game thinks that the play area is 1024x1024, that the ship’s radius is 25 on that scale. It is unfortunate to have to adjust the radii by the drawing scale factor, but guess we’re stuck with it.
Let’s add a top-level SCALE_FACTOR which we’ll set to a suitable value to get the picture we want, and we’ll apply it everywhere we can find. I do not like this solution as it means we have to find lots of little places to change things.
We’ll try to use this to help think of a better way.
With just a bit of experimentation, I come up with two sets of changes.
First, I needed to scale all the radius
members, for asteroid, ship, and saucer. Second, in SurfaceMaker, I needed these changes:
From:
@staticmethod
def create_scaled_surface(dimensions, offset, scale_factor, *point_lists):
surface = pygame.Surface(dimensions)
surface.set_colorkey((0, 0, 0))
for point_list in point_lists:
SurfaceMaker.draw_adjusted_lines(offset, point_list, scale_factor, surface)
return surface
To:
@staticmethod
def create_scaled_surface(dimensions, offset, scale_factor, *point_lists):
surface = pygame.Surface(u.SCALE_FACTOR * dimensions)
surface.set_colorkey((0, 0, 0))
# surface.fill("aqua")
for point_list in point_lists:
SurfaceMaker.draw_adjusted_lines(offset, point_list, scale_factor, surface)
return surface
That scales the surface dimensions by the scale factor. Without that, the surface is the wrong size, larger since we are scaling down, and the object is in its top right. That is probably harmless for most objects, but it made rotating the ship wrong.
I also needed to scale the line drawing in SurfaceMaker, by adjusting the points differently, from this:
@staticmethod
def adjust(point, center_adjustment, scale_factor):
return (point + center_adjustment) * scale_factor
To this:
@staticmethod
def adjust(point, center_adjustment, scale_factor):
return (point + center_adjustment) * scale_factor * u.SCALE_FACTOR
Now as I look at this, it seems to me that I could apply the scale factor on the calls to SurfaceMaker, which would be better. I may be right, but I messed something up. Roll back.
Do the changes to asteroid, saucer, and ship. And find the right solution, or at least one that works!
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)
self._offset = Vector2(self.radius, self.radius)
self._surface = SurfaceMaker.asteroid_surface(u.SCALE_FACTOR * self.radius * 2)
class Saucer(Flyer):
def __init__(self, radius, score, sound, is_small, always_target, scale):
Saucer.direction = -Saucer.direction
x = 0 if Saucer.direction > 0 else u.SCREEN_SIZE
position = Vector2(x, random.randrange(0, u.SCREEN_SIZE))
velocity = Saucer.direction * u.SAUCER_VELOCITY
self._directions = (velocity.rotate(45), velocity, velocity, velocity.rotate(-45))
self._gunner = Gunner(always_target)
self._location = MovableLocation(position, velocity)
self._radius = radius * u.SCALE_FACTOR
self._score = score
self._ship = None
self._sound = sound
self._zig_timer = Timer(u.SAUCER_ZIG_TIME)
self.is_small_saucer = is_small
self.missile_tally = 0
self.missile_head_start = 2*self._radius
self.create_surface_class_members(scale * u.SCALE_FACTOR)
class Ship(Flyer):
def __init__(self, position, drop_in=2):
self.radius = 25 * u.SCALE_FACTOR
self._accelerating = False
self._acceleration = u.SHIP_ACCELERATION
self._allow_freebie = True
self._angle = 0
self._asteroid_tally = 0
self._can_fire = True
self._drop_in = drop_in
self._hyperspace_generator = HyperspaceGenerator(self)
self._hyperspace_timer = Timer(u.SHIP_HYPERSPACE_RECHARGE_TIME)
self._location = MovableLocation(position, Vector2(0, 0))
self._missile_tally = 0
self._shipmaker = None
ship_scale = 4
ship_size = Vector2(14, 8)*ship_scale*u.SCALE_FACTOR
self._ship_surface, self._ship_accelerating_surface = SurfaceMaker.ship_surfaces(ship_size)
And that is all it takes. At scale 0.75, it looks like this:
I call that done, Commit: Game now scales to u.SCALE_FACTOR, 0.75
Let’s sum up.
Summary
The changes were few, but more than one. One might hope that a single change (well, two, counting the new constant u.SCALE_FACTOR
), but we have coupling between the screen size of the thing and its hit radius. That means that in each of asteroid, saucer, and ship, we had to apply the scale factor in two places, once in setting the object’s radius and the other in setting its surface size.
It would be easy to get that wrong. What would be ideal would be to derive either the surface dimensions from the radius or vice versa. I don’t see a useful way to do that, in part because the radii are tuned a bit for saucer and ship.
The ship’s raw pixel dimensions are 28x16, before the scale factor, and its radius is a bit less than its length, 25. The large saucer’s dimensions are something like 20x12 and its radius is 20, which means you can shoot above and below it and hit it. I need all the help I can get. In a more sophisticated program we might do something else. We could even check the hit box rectangles, I suppose.
So, point is, we can’t quite derive the radius from the surface dimensions, nor vice versa.
I’ll make a yellow sticky to remind me to look at this further.
Overall, this took me a few tries to find the way, but it was disappointingly easy. I really expected this to be a more interesting problem than it was. Why wasn’t it interesting? Not quite sheer luck, but something close to that.
We should take a look at this further, with an eye to seeing what design would better separate the concerns that belong apart and better combine the ones that belong together. I don’t think we’ll learn much about Asteroids™ by doing that, but we might learn a bit about how to build a design whose internal logic is not so tightly tied to the particular form of the drawing.
So. Not as difficult as I had anticipated … and that actually disappoints me a bit.
But the game plays better at scale 0.75. It’s a noticeable improvement!
See you next time!