Python 156 - Drop In
This morning, I woke up thinking something was broken. Fortunately, I was mistaken. Let’s fix it anyway. Also, thanks to a reader!
Thanks
The very kind Jani Poikela, on mastodon, pointed me to a slide presentation and a video on targeting. Both go into a bit more detail about how to do targeting, where the quadratic comes from, and so on.
As readers may recall, I am uncomfortable having such a critical feature implemented by a quadratic function that I cannot derive from scratch. (You can imagine how I feel about a whole game framework, but even I draw the line somewhere.) As I have degrees in mathematics, I have no doubt that I could in principle derive it, but it’s intricate enough that I am not confident that I could get it right, and I am lazy enough that just using the function was my decision.
But it is very good to know how the thing works, and to understand related targeting issues, so I am quite grateful for these helpful pointers. Thanks, Jani!
Today
I was thinking this morning about what might be done next with this program, or with Python, or with whatever, and it occurred to me that a nasty trick to play on myself would be to demand a change in the graphics layer, switching to a different graphics library. That led me to think about scaling, and that led me to the realization that scaling around a zero point is quite easy, and that led me to think about the ship’s “drop in” animation, which causes the ship to appear on screen larger than life and quickly scale down to normal size. It looks as if the ship is being dropped onto the screen.
I had a vivid recollection that this game has not been doing that, and I was concerned that I had somehow lost the feature during refactoring. Fortunately, I was mistaken: this version has never had drop in, probably in large part because I didn’t see how to do it. Or maybe I just forgot: I don’t see any references to the feature in the Python Asteroids series, and I’m sure I’d have mentioned it, even if only to say that I didn’t see how to do it.
So the good news is that it’s not broken, and the bad news is that I have asked myself to do it.
But that’s really good news, because while I think changing over to a different graphics framework would teach me (and perhaps others) some good lessons, I really don’t want to do that, at least not right now.
And therefore …
Let’s just do drop-in. It’s about right-sized for this morning.
With a vector display, drop in is easy. You center where you want to put the ship, you set the display scale larger than normal, and you draw. Boom, big ship. Then, over time, you lower the scale.
With pygame, we really only have bitmaps to display, and while they can scale, scaling a bitmap tends to mess up the bitmap. Maybe that won’t matter. Anyway let’s look into it.
Pygame has a few scaling related functions, mostly producing a new surface of a different size from the original, with and without sampling. Let’s just try something simple.
Here’s ship.draw
:
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)
Well that’s nice, isn’t it? What if we just spiked in a scaling operation there?
def draw(self, screen):
ship_source = self.select_ship_source()
rotated = pygame.transform.rotate(ship_source.copy(), self._angle)
rotated = pygame.transform.scale_by(rotated, 3)
half = pygame.Vector2(rotated.get_size()) / 2
screen.blit(rotated, self.position - half)
Um, perfect?
As you can see, the scaling is rather, what we call in the trade, jaggy. There is a smooth-scaling function as well, and I just found this function:
def draw(self, screen):
ship_source = self.select_ship_source()
rotated = pygame.transform.rotozoom(ship_source.copy(), self._angle, 3)
half = pygame.Vector2(rotated.get_size()) / 2
screen.blit(rotated, self.position - half)
It does the scaling and rotating in the one step, but for some reason messes up the free ship display, giving it a black background.
I think that for our purposes, we’ll use the two-step process, and we’ll skip the scaling call altogether if the desired scale is one.
Roll back our spike.
Plan
How should we do this? When we create a ship, we could set its drop-in scale, probably to about 2, and create a timer to bring it down to 1 in around a second. What about the scoring ones?
class ScoreKeeper(Flyer):
available_ship = Ship(Vector2(0, 0))
def draw_available_ships(self, screen):
for i in range(0, self._ship_maker.ships_remaining):
self.draw_available_ship(self.available_ship, i, screen)
def draw_available_ship(self, ship, i, screen):
position = i * Vector2(35, 0)
ship.move_to(Vector2(55, 100) + position)
ship.draw(screen)
If we’re careful to set the available_ship’s drop-in scale, this should be OK. Aside, I’m wondering why I’ve made that ship a class variable. I think that’s left over from when the game drew the available ships. I probably just adopted the existing code without a lot of actual, um, thinking. <looks around nervously.>
Come to think of it, we don’t really need a timer, we just need to tick down the scale during tick
.
Let’s put it in, with a default. I see no point writing a test for a subtract operation.
class Ship(Flyer):
def __init__(self, position, drop_in=2):
self.radius = 25
self._drop_in = drop_in
self._location = MovableLocation(position, Vector2(0, 0))
...
def tick(self, delta_time, fleets):
if self._drop_in > 1:
self._drop_in += delta_time/2
else:
self._drop_in = 1
self._hyperspace_generator.tick(delta_time)
def draw(self, screen):
ship_source = self.select_ship_source()
rotated = pygame.transform.rotate(ship_source.copy(), self._angle)
if self._drop_in > 1:
rotated = pygame.transform.scale_by(rotated, self._drop_in)
half = pygame.Vector2(rotated.get_size()) / 2
screen.blit(rotated, self.position - half)
That should do something interesting. And it does, since I’m adding to self._drop_in
, not subtracting. The ship becomes freakishly large.
With that fixed, the drop-in is too slow, we’ll use delta_time. Even that is too slow. OK, double it. That looks just about right. One more thing:
def tick(self, delta_time, fleets):
self._drop_in = self._drop_in - delta_time*2 if self._drop_in > 1 else 1
self._hyperspace_generator.tick(delta_time)
Oh, and we have to fix the available ship to be at normal scale:
class ScoreKeeper(Flyer):
available_ship = Ship(Vector2(0, 0), 1)
available_ship._angle = 90
That works as intended. I’m blessing this code. It can only be tested on the screen, unless you want to test whether subtraction works.
Commit: Ship now has 1/2 second drop-in from double size down to normal.
So that was fun, wasn’t it? Let me make a short movie, so you can enjoy it to the fullest.
Summary
How should we feel about this drop in code? I think it’s nearly as good as we could expect … or is it?
We init the ship with a drop-in scale factor. In tick, we count it down. In draw, we apply it. And we have a small hack to skip over the scaling if it has ticked down. I didn’t do that to “optimize” the code, I did it because I wanted to be sure that the final scaling was exactly 1, because scaling in pygame is so jaggy. And, given that, it made sense to skip the scaling when it’s just 1 anyway.
How could this simple feature be better? Well, honestly I think we’ll leave it this way, but let’s speculate a bit.
Suppose in draw
we had a reference to a tiny object, ShipPainter, that had the ship’s basic surface. Suppose that Ship.draw
did something like this:
self._ship_painter.paint(self.position, self.angle, screen)
And suppose that inside ShipPainter, we maintained the drop in scale and applied it in there, and then blitted the screen.
Ship would be just a bit simpler, and ShipPainter would be the only object concerned with the drop in logic. It would, I’d think, look a lot like the drop in code that’s now in Ship, but isolated. That might make it more clear what’s going on.
Should we do that? Quite possibly not. Would it be a better design? I think that by some standards, it would be. What we have is more efficient by a method call or two, but it mixes in some strange timing logic with all the things the Ship already thinks about.
Now that I’ve thought of this, maybe we’ll do it. Maybe this afternoon. Maybe tomorrow. Maybe never.
For now, we have drop-in, a very nice little feature.
See you next time!