Python Asteroids on GitHub

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:

objects too big

Here’s a screen shot of the classic game:

classic game objects much smaller

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.

small ship

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:

everything half size

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:

  1. 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.

  2. We’ll need to change all the hit radii to match the scale factor as well.

  3. 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:

scale 0.75 looks good

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!