Python 014 -Scaling the Ship
Let’s do to the ship what we did to the asteroids. And some thoughts about the future.
Regarding the future … Im wondering whether sticking with PyGame is a good idea. It’s clearly enough to do a decent Asteroids, which will suffice for my current purpose of beginning to learn Python, but I’m not convinced it’s right for the longer term. Maybe Pyglet, or Arcade. We’ll see.
But this morning, let’s look at how we draw the ship, in the light of our changes to the asteroid, and see what we might improve.
Here’s the asteroid and ship creation and all the code in SurfaceMaker:
class Asteroid:
def __init__(self, size=2, position=None):
asteroid_sizes = [32, 64, 128]
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)
class Ship:
def __init__(self, position):
self.position = position
self.velocity = vector2(0, 0)
self.angle = 0
self.acceleration = u.SHIP_ACCELERATION
self.accelerating = False
self.ship_surface, self.ship_accelerating_surface = SurfaceMaker.ship_surfaces()
class SurfaceMaker:
next_shape = 0
@staticmethod
def adjust(point, center_adjustment, scale_factor):
return (point + center_adjustment) * scale_factor
@staticmethod
def ship_surfaces():
ship_surface = SurfaceMaker.create_scaled_surface((60, 36), Vector2(7, 4), 4, raw_ship_points)
accelerating_surface = SurfaceMaker.create_scaled_surface((60, 36), Vector2(7, 4), 4,
raw_ship_points, raw_flare_points)
return ship_surface, accelerating_surface
@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
@staticmethod
def get_next_shape():
rock_shape = raw_rocks[SurfaceMaker.next_shape]
SurfaceMaker.next_shape = (SurfaceMaker.next_shape + 1) % 4
return rock_shape
@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)
I think the scheme here is that the user of the surface specifies its size, and theerefore knows its drawing offset, and that the maker makes a surface of that size (or nearly that size, since the asteroid code makes it a bit larger to allow for fat lines.)
Now the ship is presently made of two surfaces, no problem there, but the maker knows the size:
@staticmethod
def ship_surfaces():
ship_surface = SurfaceMaker.create_scaled_surface(
(60, 36), Vector2(7, 4), 4, raw_ship_points)
accelerating_surface = SurfaceMaker.create_scaled_surface(
(60, 36), Vector2(7, 4), 4, raw_ship_points, raw_flare_points)
Now 60x36 is interesting. How did I get that? The natural size of the ship is 14x8, but I used 15x9. Does it not look good without the extra margin? Let’s quickly try 56x32 and see. I’ll extract those two constants.
@staticmethod
def ship_surfaces():
length = 60
width = 36
ship_surface = SurfaceMaker.create_scaled_surface(
(length, width), Vector2(7, 4), 4, raw_ship_points)
accelerating_surface = SurfaceMaker.create_scaled_surface(
(length, width), Vector2(7, 4), 4, raw_ship_points, raw_flare_points)
return ship_surface, accelerating_surface
And then set them to 56, 32. The ship looks fine, trust me. Oh, OK, here it is:
So let’s change ship to request that size, and to set its offset based on that, similarly to how asteroid does it:
class Ship:
def __init__(self, position):
self.position = position
self.velocity = vector2(0, 0)
self.angle = 0
self.acceleration = u.SHIP_ACCELERATION
self.accelerating = False
ship_size = Vector2(56,28)
self.offset = ship_size / 2
self.ship_surface, self.ship_accelerating_surface = SurfaceMaker.ship_surfaces(ship_size)
def draw(self, screen):
ship_source = self.select_ship_source()
rotated = pygame.transform.rotate(ship_source.copy(), self.angle)
screen.blit(rotated, self.position - self.offset)
For the asteroid, we’re passing just the numeric size. For the ship, we’re passing the size vector. This will result in the maker being a bit odd. We’ll think about that. I really have no intention of allowing the scaling to be different on x and y.
Ah. I have confused myself slightly, because we cannot compute the offset for the ship. It actually depends on the rotated size of the surface. So draw has to remain as it was.
After a bit of confusion:
class Ship:
def __init__(self, position):
self.position = position
self.velocity = vector2(0, 0)
self.angle = 0
self.acceleration = u.SHIP_ACCELERATION
self.accelerating = False
ship_scale = 4
ship_size = Vector2(14, 8)*4
self.ship_surface, self.ship_accelerating_surface = SurfaceMaker.ship_surfaces(ship_size)
def draw(self, screen):
ship_source = self.select_ship_source()
rotated = pygame.transform.rotate(ship_source.copy(), self.angle)
half = pygame.Vector2(rotated.get_size()) / 2
screen.blit(rotated, self.position - half)
@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
And the ship looks good, flies well, and so on:
OK, I think that’s better. Better still might be for the entry methods to accept a Vector2 instead of the one a vector and the other a number, but I think this is fine for now. Perhaps more than fine, but I’m still trying to gain a bit of adeptness, if that’s a word, with Python.
Let’s commit this: Ship object requests desired size surface, SurfaceMaker scales accordingly.
Now PyCharm wants to tell me something. A new version, perhaps? Yes, the new version 2023.1. And congratulations to JetBrains for the most sensible version numbering I’ve ever seen. I accept the DL, the restart, and enable the new UI. I’ll give it a go, see if I like it.
PyGame
We were going to talk about PyGame. Let’s do that now.
PyGame seems fine for my current purposes, learning Python by building Asteroids for about the seventh time in my life. But it seems that it is lying fallow without updates, and that it has issues with high-resolution screens. That’s not good for the long term.
There are alternatives. Two that I’m aware of are Pyglet and Arcade. I’ll read more about those and also welcome any recommendations from either one of my readers. I think Pyglet is lighter-weight. Arcade seems to have quite a bit of substance to it, including the ability to create shaders, something with which I have almost no experience and which I probably should learn, except that I’m here for fun, and my limited experience with shaders has not been fun.
I think what I’ll try to do going forward with this version of Asteroids is to back away a bit from the graphics, trying to make sure that the drawing is quite separable from the rest, with an eye to plugging in a new graphics system after the program is “done”.
In aid of that, I’ll try to do more with tests, so that the game is tested and running and the display is mostly an after-thought. This will have at least one graphics-related impact. PyGame includes the ability to check whether surfaces, or their rectangles, are colliding. We won’t be able to use that sort of thing. On the other hand, I never have used those before, instead preferring to check the distance between things. We’ll continue with that approach.
So. More tests. More features. Drawing, but not focusing so much on it.
And at the end … maybe … plug in a whole new graphics underpinning.
Should be fun.
Decorators
I’ve been studying Python in a sort of casual fashion, and because I used the @staticmethod
decorator the other day, I was interested to learn a bit about decorators in general, and I found out that they are essentially a wrapper for higher-order functions, and that you can write your own decorators or use the many that others have written. I’ve found that there exist decorators to help with something we’ve talked about.
I was thinking that it would be interesting if the SurfaceMaker were to cache the surfaces it makes, allowing us to create and destroy asteroids at will, without paying the price for setting up the surface each time. It turns out that there exist many user-provided decorators for this purpose, often named memoize
or some version of that name. If we were to do it, I’m sure I’d roll my own, and honestly I don’t think it’s worth it in a game of this size, but it’ll make an interesting topic to explore someday. Perhaps.
Summary
Ship and Asteroid now manage their surfaces more similarly than they did, and both provide the possibility of arbitrary scaling. This feature was driven by my discovery that PyGame is doubling the size of my requested window, which messes up my original scaling, which was already pretty arbitrary. Now we have code that’s somewhat more clear and quite flexible.
Just a little play this morning. Now I have to go off, surf the internet, and get fooled. I always fall for something on April 1.
See you next time!