Python 003 - Inch By Inch
I’ve learned two ways to make the ship transparent. One of them is a good way.
Yesterday, I found that I could add a “blend mode” to a blit. the blend modes are like those in Photoshop or Illustrator or Procreate, although there are fewer of them. Setting my blit to BLEND_ADD made the ship’s lines show but not its black surroundings. That was not the way to do it, although color on black would probably work that way.
Watching a video from DaFluffyPotato, I saw an example of the Surface.set_colorkey
method. The method accepts a color parameter and that color is treated as transparent when the surface is blitted. So if we do this:
class Ship:
def __init__(self, position):
self.position = position
self.raw_points = [vector2(-3.0, -2.0), vector2(-3.0, 2.0), vector2(-5.0, 4.0),
vector2(7.0, 0.0), vector2(-5.0, -4.0), vector2(-3.0, -2.0)]
self.raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
self.surface = pygame.Surface((60, 36))
self.surface.set_colorkey((0, 0, 0)) # <---
self.paint(self.surface)
We get this:
So that’s nice.
I’m about tired of that size screen, however, so let’s get down to a smaller size.
screen = pygame.display.set_mode((512, 512))
Rotation
That’ll work for now and save on picture size when we take pics. Now let’s work on rotation. We’ll give the ship an angle and make it spin. We’ll use pygame.transform.rotate
, which takes a surface, and an angle in degrees.
My reading tells me that it’s best to start with a clean copy of your image and rotate that, rather than incrementally rotating it again and again, because it will degrade as the rotation rearranges the pixels. I’ll take that as truth and do my rotate on a copy.
We’ll do the work in the draw function, after adding an angle that starts at 0 degrees.
class Ship:
def __init__(self, position):
self.position = position
self.angle = 0
...
def draw(self, screen):
copy = pygame.transform.rotate(self.surface.copy(), self.angle)
screen.blit(copy, self.position)
And we’d best change the angle in our main loop. We’re running 60 times a second. If we increment it by 1, that’ll be 60 degrees per second, six seconds around. We’ll start there.
while running:
# poll for events
# pygame.QUIT event means the user clicked X to close your window
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# fill the screen with a color to wipe away anything from last frame
screen.fill("purple")
pygame.draw.circle(screen, "red", player_pos, 40)
ship.angle += 1
ship.draw(screen)
...
That works, but not quite as we might like:
Right. It’s rotating around the top left corner, because that’s how we position it when we draw it. We’ve never put it at the actual center, as you can see in the original picture above. We want to position our blit so that the image center is at the ship position. We’ll just subtract half the rectangle size from the position, to shift it left and up:
def draw(self, screen):
copy = pygame.transform.rotate(self.surface.copy(), self.angle)
half = pygame.Vector2(copy.get_size())/2
screen.blit(copy, self.position - half)
Line Width
The rotation is good. The ship is a bit vague. Let’s draw it with thicker lines:
def paint(self, surface):
ship_points = list(map(self.adjust, self.raw_points))
pygame.draw.lines(surface, "white", False, ship_points, 3)
The 3
is the line width. That looks better:
Perhaps a bit jaggy, but when it’s moving it looks OK. We do have an alternative for rotation, in that we could rotate the points and draw lines between them, which might give us better anti-aliasing, but this is a last century video game, so a little jaggy is just fine, gives it a nice retro flair.
What else might we do?
Flare
We could deal with the flare not flair, the occasional display of the exhaust flare when the ship is accelerating. Given that we blit the way we do, I think the right approach will be to cache images of the ship with and without flare, then at the last moment, select which one to draw and rotate it into position.
Let’s do that. We’ll paint a second image in paint
. We’ll refactor a bit, starting here:
def __init__(self, position):
self.position = position
self.angle = 0
self.raw_points = [vector2(-3.0, -2.0), vector2(-3.0, 2.0), vector2(-5.0, 4.0),
vector2(7.0, 0.0), vector2(-5.0, -4.0), vector2(-3.0, -2.0)]
self.raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
self.surface = pygame.Surface((60, 36))
self.surface.set_colorkey((0, 0, 0))
self.paint(self.surface)
def paint(self, surface):
ship_points = list(map(self.adjust, self.raw_points))
pygame.draw.lines(surface, "white", False, ship_points, 3)
Let’s create the surfaces inside paint
. First move those lines and remove the parameter.
Ah, python doesn’t like that. It wants us to define all the fields in the init. Good call, I’ll go along with it.
def __init__(self, position):
self.position = position
self.angle = 0
self.raw_points = [vector2(-3.0, -2.0), vector2(-3.0, 2.0), vector2(-5.0, 4.0),
vector2(7.0, 0.0), vector2(-5.0, -4.0), vector2(-3.0, -2.0)]
self.raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
self.surface = pygame.Surface((60, 36))
self.surface.set_colorkey((0, 0, 0))
self.flare_surface = pygame.Surface((60, 36))
self.flare_surface.set_colorkey((0, 0, 0))
self.paint(self.surface, self.flare_surface)
Now in paint
:
def paint(self, surface, flare_surface):
ship_points = list(map(self.adjust, self.raw_points))
flare_points = list(map(self.adjust, self.raw_flare))
pygame.draw.lines(surface, "white", False, ship_points, 3)
pygame.draw.lines(flare_surface, "white", False, ship_points, 3)
pygame.draw.lines(flare_surface, "white", False, flare_points, 3)
That looks right. I’ll test by drawing the flare_surface all the time just to see how it looks. Perfect:
Now, if accelerating, we want to use the flare surface 1/3 of the time.
For now at least, I’ll add a flag accelerating
. We’re just spiking here, to learn how to do things.
self.position = position
self.angle = 0
self.accelerating = False
Now I have to learn how to do a random number. That seemed easy, we import random
and then do this:
def draw(self, screen):
if not self.accelerating or random.random() < 0.66:
ship_source = self.surface
else:
ship_source = self.flare_surface
copy = pygame.transform.rotate(ship_source.copy(), self.angle)
half = pygame.Vector2(copy.get_size())/2
screen.blit(copy, self.position - half)
We select our source and use it. Easy enough, and when accelerating, it looks like this:
I haven’t committed any of this. Let’s do that. Commit: ship can rotate and show acceleration flare.
We also haven’t tested anything, but it has all been graphics, so there isn’t much to test. I feel somewhat concerned about that but I think we’re OK.
Let’s review Ship class. I think we could use a bit of improvement, if only to the names:
import pygame
import random
vector2 = pygame.Vector2
class Ship:
def __init__(self, position):
self.position = position
self.angle = 0
self.accelerating = False
self.raw_points = [vector2(-3.0, -2.0), vector2(-3.0, 2.0), vector2(-5.0, 4.0),
vector2(7.0, 0.0), vector2(-5.0, -4.0), vector2(-3.0, -2.0)]
self.raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
self.surface = pygame.Surface((60, 36))
self.surface.set_colorkey((0, 0, 0))
self.flare_surface = pygame.Surface((60, 36))
self.flare_surface.set_colorkey((0, 0, 0))
self.paint(self.surface, self.flare_surface)
def adjust(self, point):
return point*4 + vector2(28, 16)
def draw(self, screen):
if not self.accelerating or random.random() < 0.66:
ship_source = self.surface
else:
ship_source = self.flare_surface
copy = pygame.transform.rotate(ship_source.copy(), self.angle)
half = pygame.Vector2(copy.get_size())/2
screen.blit(copy, self.position - half)
def paint(self, surface, flare_surface):
ship_points = list(map(self.adjust, self.raw_points))
flare_points = list(map(self.adjust, self.raw_flare))
pygame.draw.lines(surface, "white", False, ship_points, 3)
pygame.draw.lines(flare_surface, "white", False, ship_points, 3)
pygame.draw.lines(flare_surface, "white", False, flare_points, 3)
Let’s rename raw_points
and/or raw_flare
to be more consistent.
self.raw_ship = [vector2(-3.0, -2.0), vector2(-3.0, 2.0), vector2(-5.0, 4.0),
vector2(7.0, 0.0), vector2(-5.0, -4.0), vector2(-3.0, -2.0)]
self.raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
Right. Now the surfaces should be something like ship and ship with flare?
def __init__(self, position):
self.position = position
self.angle = 0
self.accelerating = False
self.raw_ship = [vector2(-3.0, -2.0), vector2(-3.0, 2.0), vector2(-5.0, 4.0),
vector2(7.0, 0.0), vector2(-5.0, -4.0), vector2(-3.0, -2.0)]
self.raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
self.ship_surface = pygame.Surface((60, 36))
self.ship_surface.set_colorkey((0, 0, 0))
self.ship_accelerating_surface = pygame.Surface((60, 36))
self.ship_accelerating_surface.set_colorkey((0, 0, 0))
self.paint(self.ship_surface, self.ship_accelerating_surface)
PyCharm kindly renames the other occurrences:
if not self.accelerating or random.random() < 0.66:
ship_source = self.ship_surface
else:
ship_source = self.ship_accelerating_surface
copy = pygame.transform.rotate(ship_source.copy(), self.angle)
half = pygame.Vector2(copy.get_size())/2
screen.blit(copy, self.position - half)
Now then, do we really need to pass those surfaces in to paint
?
def paint(self, surface, flare_surface):
ship_points = list(map(self.adjust, self.raw_ship))
flare_points = list(map(self.adjust, self.raw_flare))
pygame.draw.lines(surface, "white", False, ship_points, 3)
pygame.draw.lines(flare_surface, "white", False, ship_points, 3)
pygame.draw.lines(flare_surface, "white", False, flare_points, 3)
I’d prefer not to. They are member variables after all.
Let’s try change signature and see what it does. It fixes the definition and the call, and highlights the now missing parameters. I change them:
def paint(self):
ship_points = list(map(self.adjust, self.raw_ship))
flare_points = list(map(self.adjust, self.raw_flare))
pygame.draw.lines(self.ship_surface, "white", False, ship_points, 3)
pygame.draw.lines(self.ship_accelerating_surface, "white", False, ship_points, 3)
pygame.draw.lines(self.ship_accelerating_surface, "white", False, flare_points, 3)
That’ll do. Commit: tidying Ship.
What else? How about this:
def draw(self, screen):
if not self.accelerating or random.random() < 0.66:
ship_source = self.ship_surface
else:
ship_source = self.ship_accelerating_surface
copy = pygame.transform.rotate(ship_source.copy(), self.angle)
half = pygame.Vector2(copy.get_size())/2
screen.blit(copy, self.position - half)
We could extract that if to good effect, I think. Extract Method does this:
def draw(self, screen):
ship_source = self.select_ship_source()
copy = pygame.transform.rotate(ship_source.copy(), self.angle)
half = pygame.Vector2(copy.get_size())/2
screen.blit(copy, self.position - half)
def select_ship_source(self):
if not self.accelerating or random.random() < 0.66:
ship_source = self.ship_surface
else:
ship_source = self.ship_accelerating_surface
return ship_source
I can imagine something better for the select
. Let’s see if PyCharm offers any suggestions. Well, it does offer to invert the if. I had been thinking about that anyway:
if self.accelerating and random.random() >= 0.66:
ship_source = self.ship_accelerating_surface
else:
ship_source = self.ship_surface
return ship_source
Very nice job on the greater than, PyCharm! But let’s embed the returns.
def select_ship_source(self):
if self.accelerating and random.random() >= 0.66:
return self.ship_accelerating_surface
else:
return self.ship_surface
In Kotlin, I could lift the return out of the if. Python is a bit more retro in that regard. I don’t mind that but my Kotlin-honed reflexes make me want to do it.
It turns out that we could say this:
def select_ship_source(self):
return self.ship_accelerating_surface if self.accelerating and random.random() >= 0.66 else self.ship_surface
But that line is way too long to read, and it appears that you can’t just go around folding it. I’ll stick with what I had.
I think that’ll do for the morning. I’ve been fiddling for nearly two hours, it’s time for a break. Let’s sum up.
Summary
Learnings may include:
- Use
set_colorkey
to make a transparent background using surface blit. - Use
transform.rotate
with degrees to rotate an image prior to blitting. - Create two versions of an image for simplistic animation.
- Suffix if-else (ternary) exists but rather cryptic.
I’m slowly gaining a grasp of what’s in PyGame, but there’s a lot and the documentation is almost entirely bereft of examples. There are plenty of videos on the web, so one can find things to watch. The main issue is that you get to sit through 5, 15, or 30 minutes of video to learn about one or two techniques, and some of the videos are better than others, if you know what I mean. Still, any info is good info when you’re as ignorant as I am.
There are really quite a few videos showing the full implementation of games, mostly platformers but some other interesting ones as well. And some of the platformers are quite rich in content.
To be honest, you’d probably learn more watching those than watching me. But I’m having fun and if you care to follow along, you’re certainly welcome. And feel free to toot me up, ronjeffries at mastodon dot social. I’m still on Twitter but I think I’ll bail from there pretty soon. Perhaps when they remove my blue checkmark, or when the Nazi level gets over 50% of what they show me.
In any case, if you’re out there, let me know, and good fortune to you all. See you next time!