My search for why the window is double-sized caused me to observe that I can’t scale my objects to arbitrary sizes. I think part of the issue is coupling, so that’s interesting.

Research suggests to me that PYGame is forcing the size of my windows to double because it assumes that with a “retina” display, I don’t know what I’m doing and will otherwise draw things too small. It could be right about me not knowing what I’m doing. It appears, further, that for the Mac, there is no fix. Also people complain that PyGame hasn’t been updated for years.

All this would concern me if I weren’t just learning, but at least for now I mostly don’t care.

However, discovering that difficulty led me to the notion of rescaling the sizes of the ship and asteroid, tuning them for best appearance on whatever screen I can get it to display. This morning, I want to explore how we move from the basic asteroid data to a scaled surface, and to work out how best to express the scale in a simple, sensible way.

When the Asteroid is created, it sets up its offset based on assumptions about the size. I’m trying a try-except here, rather than the call to clamp that we had before.

class Asteroid:
    def __init__(self, size=2, position=None):
        self.position = position if position else Vector2(0, random.randrange(0, u.SCREEN_SIZE))
        try:
            offset_amount = [16, 32, 64][size]
        except IndexError as e:
            offset_amount = 64
        self.offset = Vector2(offset_amount, offset_amount)
        angle_of_travel = random.randint(0, 360)
        self.velocity = u.ASTEROID_SPEED.rotate(angle_of_travel)
        self.surface = SurfaceMaker.asteroid_surface(size)

    def draw(self, screen):
        top_left_corner = self.position - self.offset
        pygame.draw.circle(screen, "red", self.position, 3)
        screen.blit(self.surface, top_left_corner)

The offset is used in draw. If we were to draw at the asteroid’s position, the top left corner of the picture would be drawn at that position. That would be bad. We get the offset from the input size, which is 0, 1, 2. The offset code is assuming that the asteroid sizes will be 32x32, 64x64, or 128x128. That means that when we pass size to the asteroid_surface code, it had better make an asteroid that’s 32, 64, or 128 square, or the offset will be wrong.

That, friends, is coupling. The asteroid_surface method and the __init__ method have to agree on those numbers. And there’s nothing in the code to ensure that agreement. Here’s the other method:

    @staticmethod
    def asteroid_surface(size):
        shape = SurfaceMaker.next_shape
        SurfaceMaker.next_shape = (SurfaceMaker.next_shape + 1) % 4
        scale = [4, 8, 16][clamp(size, 0, 2)]
        room_for_fat_line = 2
        surface_size = 8*scale + room_for_fat_line
        offset = Vector2(4, 4)
        surface = SurfaceMaker.create_scaled_surface((surface_size, surface_size), offset, scale, raw_rocks[shape])
        x, y = surface.get_size()
        return surface

I’ll do a quick explanation of this. It could use improvement. Last night I changed the random selection of asteroid shape to cycle through the shapes, which reduces the duplication of a single shape to a minimum. That’s the first two lines. Should be extracted as a method, probably. My excuse is that I only just wrote it last night. No excuse, sir, but an explanation.

Then we set scale to one of three random numbers, 4, 8, and 16, corresponding to the 16, 32, 64 of the offset calculation, corresponding to the 32, 64, 128 size we plan to wind up with. As I describe this I begin to understand why I find this confusing.

Then we set the surface size to 8 times the scale, which, if the numbers are all correct, will give us 32, 64, and 128, the size we actually want, except for a little extra, room_for_fat__line, because our fat lines go over the edge. So the logical size of the surface is 32, 64, 128, its proper offset is 16, 32, 64, and it’s got a little extra room in the pants.

Then we call create_scaled_surface, passing in the actual surface size, a constant 4,4 offset, which I’ll describe in a moment, and the scale, the magic number 4, 8, or 16. And that method looks like this:

    @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:
            adjusted = [SurfaceMaker.adjust(point, offset, scale_factor) for point in point_list]
            pygame.draw.lines(surface, "white", False, adjusted, 3)
        return surface

We make the surface to the spec, and set its transparency color_key. Good so far. Then we process the point_lists, of which there could be more than one. We should do some extracting here, but we have excuses if not reasons.

For each point in our list of points, we adjust each one, according to the provided offset and scale_factor, producing an adjusted list. Then we draw the lines.

    @staticmethod
    def adjust(point, center_adjustment, scale_factor):
        return (point + center_adjustment) * scale_factor

Here we see a better name for that Vector2(4,4): center_adjustment. The input points for our shapes are centered around the center of the object, and asteroids’ raw shapes range from -4 to +4 in both x and y. So the center of the asteroid is (4,4) away from the top corner. We want -4,-4 in the asteroid’s raw points to translate to (0,0) in the surface. So we add the center_adjustment to each point and then scale by the scale_factor.

Whew. This looks a lot like something we just threw together, doesn’t it? And, in fact, it is exactly that. At the time, it made sense, because we “knew” we just wanted 32, 64 and 128 pixel asteroids, because that had worked fine in the past. But our assumptions about that aren’t well represented anywhere, and different aspects are represented in different places. Issues include:

  1. In init, we represent those sizes as 16, 32, 64 and use that to draw the surface;
  2. In adjust, we are provided a scale factor that must be 4, 8, or 16 because 8 times those values is 32, 64, 128, which are the agreed sizes of asteroids that no one seems to actually know;
  3. The center_adjustment value is about the raw values and probably should be better named;
  4. Probably more issues that don’t come to mind …

Let me try to reason about this.

  1. We start with a shape that spans -4,-4, to 4,4.
  2. We translate that shape so that it spans 0,0 to 8,8.
  3. We want to scale it to some size 32, 64, 128, or for that matter 29, 47, 93, so we should multiply each point by size/8, so that when we scale 8 it becomes 8*size/8, or size.

It seems to me that the asteroid creation should know what size it wants the asteroids to be, and that it should pass that information to the surface maker. Let’s try to push that through.

    def __init__(self, size=2, position=None):
        asteroid_sizes = [29, 47, 93]
        try:
            asteroid_size = asteroid_sizes[size]
        except IndexError:
            asteroid_size = asteroid_sizes[2]
        self.offset = Vector2(asteroid_size/2, asteroid_size/2)
        self.position = position if position else Vector2(0, random.randrange(0, u.SCREEN_SIZE))
        angle_of_travel = random.randint(0, 360)
        self.velocity = u.ASTEROID_SPEED.rotate(angle_of_travel)
        self.surface = SurfaceMaker.asteroid_surface(asteroid_size)

That looks right to me. We’re changing the meaning of the parameter to asteroid_surface to be the acual size we want, and we set our offset to half that size.

    @staticmethod
    def asteroid_surface(actual_size):
        shape = SurfaceMaker.next_shape
        SurfaceMaker.next_shape = (SurfaceMaker.next_shape + 1) % 4
        scale = actual_size/8
        room_for_fat_line = 2
        surface_size = actual_size + room_for_fat_line
        offset = Vector2(4, 4)
        surface = SurfaceMaker.create_scaled_surface((surface_size, surface_size), offset, scale, raw_rocks[shape])
        return surface

Here we compute the scale based on our knowledge of the input data. We should fix these magic numbers. We adjust the surface size a bit, for fat lines, then draw. It seems to work. I tweak the input numbers to get these nice pictures, size 93, 37, and 200 respectively.

size 93

size 37

size 200

OK, that’s good. Commit: Change asteroid sizing to accept actual desired size.

Now let’s see if we can make this code a bit more clear.

    @staticmethod
    def asteroid_surface(actual_size):
        raw_points_span = 8
        raw_points_offset = Vector2(4, 4)
        shape = SurfaceMaker.next_shape
        SurfaceMaker.next_shape = (SurfaceMaker.next_shape + 1) % 4
        scale = actual_size/raw_points_span
        room_for_fat_line = 2
        surface_size = actual_size + room_for_fat_line
        surface = SurfaceMaker.create_scaled_surface((surface_size, surface_size), raw_points_offset, scale, raw_rocks[shape])
        return surface

Giving the magic numbers names helps. Let’s do an extract and reorder things a bit. I think I’ll need to do this by hand: I don’t quite see a machine-refactoring to get what I want. Let’s try, though.

Rename shape to what it is:

    @staticmethod
    def asteroid_surface(actual_size):
        shape_index = SurfaceMaker.next_shape
        SurfaceMaker.next_shape = (SurfaceMaker.next_shape + 1) % 4
        scale = actual_size/8
        room_for_fat_line = 2
        surface_size = actual_size + room_for_fat_line
        offset = Vector2(4, 4)
        surface = SurfaceMaker.create_scaled_surface((surface_size, surface_size), offset, scale, raw_rocks[shape_index])
        return surface

Why am I doing this, when I could surely code it? I’m doing it because machine refactoring is safer than my programming. I’m more likely to wind up with something that works this way. If I use only machine refactoring, I’m essentially guaranteed not to break anything.

Now extract a variable:

    @staticmethod
    def asteroid_surface(actual_size):
        shape_index = SurfaceMaker.next_shape
        SurfaceMaker.next_shape = (SurfaceMaker.next_shape + 1) % 4
        scale = actual_size/8
        room_for_fat_line = 2
        surface_size = actual_size + room_for_fat_line
        offset = Vector2(4, 4)
        rock_shape = raw_rocks[shape_index]
        surface = SurfaceMaker.create_scaled_surface((surface_size, surface_size), offset, scale, rock_shape)
        return surface

Move that up:

    @staticmethod
    def asteroid_surface(actual_size):
        shape_index = SurfaceMaker.next_shape
        rock_shape = raw_rocks[shape_index]
        SurfaceMaker.next_shape = (SurfaceMaker.next_shape + 1) % 4
        scale = actual_size/8
        room_for_fat_line = 2
        surface_size = actual_size + room_for_fat_line
        offset = Vector2(4, 4)
        surface = SurfaceMaker.create_scaled_surface((surface_size, surface_size), offset, scale, rock_shape)
        return surface

Inline:

    def asteroid_surface(actual_size):
        rock_shape = raw_rocks[SurfaceMaker.next_shape]
        SurfaceMaker.next_shape = (SurfaceMaker.next_shape + 1) % 4
        scale = actual_size/8
        room_for_fat_line = 2
        surface_size = actual_size + room_for_fat_line
        offset = Vector2(4, 4)
        surface = SurfaceMaker.create_scaled_surface((surface_size, surface_size), offset, scale, rock_shape)
        return surface

Now what I really want to do is to extract those first two lines into a method get_next_shape. I don’t think it’ll let me do that, but I’ll try. Yes! If I select those two lines and extract method, I get this:

    @staticmethod
    def asteroid_surface(actual_size):
        rock_shape = SurfaceMaker.get_next_shape()
        scale = actual_size/8
        room_for_fat_line = 2
        surface_size = actual_size + room_for_fat_line
        offset = Vector2(4, 4)
        surface = SurfaceMaker.create_scaled_surface((surface_size, surface_size), offset, scale, rock_shape)
        return surface

    @staticmethod
    def get_next_shape():
        rock_shape = raw_rocks[SurfaceMaker.next_shape]
        SurfaceMaker.next_shape = (SurfaceMaker.next_shape + 1) % 4
        return rock_shape

Wait, I did a rollback there for a false start and I lost my magic names. Let’s put those back, with a little improvement:

    @staticmethod
    def asteroid_surface(actual_size):
        raw_rock_points = SurfaceMaker.get_next_shape()
        raw_points_span = 8
        raw_points_offset = Vector2(4, 4)
        scale = actual_size / raw_points_span
        room_for_fat_line = 2
        surface_size = actual_size + room_for_fat_line
        surface = SurfaceMaker.create_scaled_surface((surface_size, surface_size), 
                                                     raw_points_offset, scale, raw_rock_points)
        return surface

That’s about as good as I can do this morning. Let’s commit: Refactoring asteroid_surface for clarity.

I do one more quick extract, mostly because I want to make sure PyCharm is auto-running my tests:

    @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

    @staticmethod
    def draw_adjusted_lines(offset, point_list, scale_factor, surface):
        adjusted = [SurfaceMaker.adjust(point, offset, scale_factor) for point in point_list]
        pygame.draw.lines(surface, "white", False, adjusted, 3)

Enough. Commit: Extract Method.

Summary

Exploration of a display issue led me to realize that I couldn’t scale my asteroids to an arbitrary size. A bit of improvement and then some nice inch-by-inch refactoring has added the capability to have asteroids of any given size, and code that, I think, is more clear.

Lessons? I was pleased to be able to do those changes with essentially no real code creation, just renaming and the like. A tribute to PyCharm, but also a little credit to me in finding a way to improve the code without much risk of breaking it.

And it’s fun, a little puzzle: how can I use the machine refactorings to get this code to be more clear? Quite enjoyable, really.

Enough for now, I’ll see you next time!