Python 73 - WAY over the top!
Today I plan to add gold to the gilded lily, in the form of still more enhancements to the Ship’s explosion. Unjustified, but I plan to be amused by the result. And I am.
Currently we have some number of Fragments, five, I believe, that make up the ship’s explosion. They are arranged roughly around a circle, each one is represented by a line of random length, they each rotate at a different speed, and each one has a different velocity.
That’s not good enough. My rough story for the explosion is:
- There will be (5) Fragments, plus one VFragment and one GFragment, in the explosion;
- The fragments will be roughly distributed in a circle around the explosion point;
- The angle of each will be slightly randomized so that it’s not too obviously a circle;
- The VFragment and GFragment will appear at a random selection of the (7) angles, so that they aren’t always visibly the same from explosion to explosion;
- The fragments will start white and fade as they move outward.
Seems like a lot for an effect that currently runs for one and a half seconds, doesn’t it? And yes, it is. We’re here for fun and this will be fun for everyone.
Explanation-ish
Here’s how things work now. The ExplosionFleet class can populate itself:
class ExplosionFleet:
def explosion_at(self, position):
how_many = 5
for i in range(how_many):
twiddle = random.randrange(-20, 20)
fragment = Fragment(position=position, angle=360 * i / how_many + twiddle)
self.flyers.append(fragment)
The Fragment class, the only one we have so far, inits instances like this:
class Fragment:
def __init__(self, position, angle=None, speed_mul=None):
angle = angle if angle is not None else random.randrange(360)
self.position = position
half_length = random.uniform(6, 10)
self.begin = Vector2(-half_length, 0)
self.end = Vector2(half_length, 0)
speed_mul = speed_mul if speed_mul is not None else random.uniform(0.25, 0.5)
self.velocity = speed_mul*Vector2(u.FRAGMENT_SPEED, 0).rotate(angle)
self.theta = random.randrange(0, 360)
self.delta_theta = random.uniform(180, 360)*random.choice((1, -1))
self.timer = Timer(u.FRAGMENT_LIFETIME, self.timeout)
def draw(self, screen):
begin = self.position + self.begin.rotate(self.theta)
end = self.position + self.end.rotate(self.theta)
pygame.draw.line(screen, "red", begin, end, 3)
def move(self, delta_time):
position = self.position + self.velocity * delta_time
position.x = position.x % u.SCREEN_SIZE
position.y = position.y % u.SCREEN_SIZE
self.position = position
self.theta += self.delta_theta*delta_time
Amazing already. Let me explain some of what’s going on.
Start with move
:
The Fragment has a position
and an angle theta
. It consists of a line that will be drawn centered on position
at angle theta
. When the Fragment gets sent move
, it adjusts its position
using its velocity
, like everyone does, and it adjusts theta
by a time-scaled delta_theta
, a random angle change that gives each Fragment its own rotation rate.
Now draw
:
The Fragment has begin
and end
variables, which are normalized vectors from (-L, 0) to (L, 0), where L is the length of the line. If drawn like that, you’d get a horizontal line centered at position
. But in draw
, we rotate those two points by the current angle theta
, which makes the line rotate, because its ends are rotating. We do that math on the base values, then add in the current position so that the line appears where it should.
Now init
:
The input position
is the starting center of the explosion. In use it is the position of the ship when it was destroyed. The angle
can be provided, for testing purposes, and it is the angle at which the fragment will move away from the center. The explosion code in ExplosionFleet does specify angle
as well. The speed_mul
value is only there for testing, so that I could test the move operation with a known value.
The init calculates a random half-length
for the Fragment’s line, and saves the initial values of begin
and end
. Then we compute a random speed_mul
that’s hand-tuned to get the Fragments to move outward at a speed that looks good. That value is applied to the base Fragment speed u.FRAGMENT_SPEED
, and rotated by the angle
at which we want this Fragment to move. Now we have the Fragment’s velocity
.
Then we pick a random starting angle theta
and a random delta_theta
, which can be either positive or negative, so that the Fragment may rotate clockwise or counter-clockwise.
Whew!
And we’re just getting started.
Plan-ish
I plan to have two more classes of fragment, VFragment and GFragment, which will basically only differ in what they draw. Whether they are subclasses of Fragment or separate classes or whether we have some kind of drawing strategy, I haven’t decided yet. (You might think that at this stage I would have a decision, except that you’ve been paying attention all these years and you know that even after a thing is done I haven’t made a final decision and might change it.
I do think a subclass will be easy, and that perhaps delegation of just the drawing part might be “best” in some sense, but we’ll see.
I want to start first with the creation of the whole explosion of fragments anyway.
What I’d kind of like is to put N Fragments and a VFragment and a GFragment in a bag, and draw them out one at a time randomly, distributing them around the “circle”, tweaking the angle somewhat like we did now.
I see that Python has a random shuffle of a list. We can use that, which will cause our classes to be built in random order around the clock.
I don’t see a good way to test use of a random shuffle. Let’s refactor what we have, starting here:
def explosion_at(self, position):
how_many = 5
for i in range(how_many):
twiddle = random.randrange(-20, 20)
fragment = Fragment(position=position, angle=360 * i / how_many + twiddle)
self.flyers.append(fragment)
I think what I’d like to have is for Fragment
there to be a variable, fragment_class
and call that to create whatever kind of Fragment we want. Let’s do this:
def explosion_at(self, position):
how_many = 5
for i in range(how_many):
fragment_class = Fragment
twiddle = random.randrange(-20, 20)
fragment = fragment_class(position=position, angle=360 * i / how_many + twiddle)
self.flyers.append(fragment)
Now extract a variable:
def explosion_at(self, position):
how_many = 5
for i in range(how_many):
fragment_class = Fragment
twiddle = random.randrange(-20, 20)
base_direction = 360 * i / how_many
fragment = fragment_class(position=position, angle=base_direction + twiddle)
self.flyers.append(fragment)
Move base_direction
up:
def explosion_at(self, position):
how_many = 5
for i in range(how_many):
fragment_class = Fragment
base_direction = 360 * i / how_many
twiddle = random.randrange(-20, 20)
fragment = fragment_class(position=position, angle=base_direction + twiddle)
self.flyers.append(fragment)
Tests are running green, by the way, but do I even have a test for this method? Only a rudimentary one:
def test_explosion_fleet(self):
fleet = ExplosionFleet()
explosion = fleet.flyers
fleet.explosion_at(u.CENTER)
assert explosion
That’s enough to ensure that the thing runs. Let’s do better. We’ll have to assume the number of items we want. I’ll say 7, which will break the test, then we’ll fix the code.
def test_explosion_fleet(self):
fleet = ExplosionFleet()
explosion = fleet.flyers
fleet.explosion_at(u.CENTER)
assert len(explosion) == 7
Fix the code:
def explosion_at(self, position):
how_many = 7
for i in range(how_many):
fragment_class = Fragment
base_direction = 360 * i / how_many
twiddle = random.randrange(-20, 20)
fragment = fragment_class(position=position, angle=base_direction + twiddle)
self.flyers.append(fragment)
Now let’s enhance to expect a VFragment and GFragment. Well, part way there. Let’s count the Fragments expecting only 5, and enhance the test to check the other classes when we’re ready to create them.
def test_explosion_fleet(self):
fleet = ExplosionFleet()
explosion = fleet.flyers
fleet.explosion_at(u.CENTER)
assert len(explosion) == 7
frags = [fragment for fragment in explosion
if type(fragment) == Fragment]
assert len(frags) == 5
OK then. We need to make that test run. Some refactoring first, extracting a method:
def explosion_at(self, position):
how_many = 7
for i in range(how_many):
fragment_class = Fragment
base_direction = 360 * i / how_many
self.make_fragment(base_direction, fragment_class, position)
def make_fragment(self, base_direction, fragment_class, position):
twiddle = random.randrange(-20, 20)
fragment = fragment_class(position=position, angle=base_direction + twiddle)
self.flyers.append(fragment)
Now then. We have a nice new method, make_fragment
that will create an instance of anything that will accept that calling sequence. We have in mind, of course, GFragment and VFragments. But first let’s keep getting it wrong:
def explosion_at(self, position):
fragment_classes = [Fragment, Fragment, Fragment, Fragment, Fragment, Fragment, Fragment]
how_many = len(fragment_classes)
for i in range(how_many):
fragment_class = fragment_classes[i]
base_direction = 360 * i / how_many
self.make_fragment(base_direction, fragment_class, position)
def make_fragment(self, base_direction, fragment_class, position):
twiddle = random.randrange(-20, 20)
fragment = fragment_class(position=position, angle=base_direction + twiddle)
self.flyers.append(fragment)
Suddenly we’re looking up which class to make. Now, I think I’d like to create the new classes, and I think I’ll make them subclasses of Fragment just because it will make the change trivial.
class VFragment(Fragment):
def __init__(self, position, angle=None, speed_mul=None):
super().__init__(position, angle, speed_mul)
Now what would happen if I put two VFragments into that collection?
def explosion_at(self, position):
fragment_classes = [VFragment, VFragment, Fragment, Fragment, Fragment, Fragment, Fragment]
how_many = len(fragment_classes)
for i in range(how_many):
fragment_class = fragment_classes[i]
base_direction = 360 * i / how_many
self.make_fragment(base_direction, fragment_class, position)
My test would run green, that’s what would happen. Commit: trivial VFragment driven out and used in explosion_at.
In for a penny, let’s do GFragment the same way:
class GFragment(Fragment):
def __init__(self, position, angle=None, speed_mul=None):
super().__init__(position, angle, speed_mul)
And:
def explosion_at(self, position):
fragment_classes = [VFragment, GFragment, Fragment, Fragment, Fragment, Fragment, Fragment]
random.shuffle(fragment_classes)
how_many = len(fragment_classes)
for i in range(how_many):
fragment_class = fragment_classes[i]
base_direction = 360 * i / how_many
self.make_fragment(base_direction, fragment_class, position)
Now I’m sure that we have a V and G and 5 F’s but the test isn’t so sure. We’ll enhance it.
def test_explosion_fleet(self):
fleet = ExplosionFleet()
explosion = fleet.flyers
fleet.explosion_at(u.CENTER)
assert len(explosion) == 7
frags = [fragment for fragment in explosion
if type(fragment) == Fragment]
assert len(frags) == 5
frags = [fragment for fragment in explosion
if type(fragment) == VFragment]
assert len(frags) == 1
frags = [fragment for fragment in explosion
if type(fragment) == GFragment]
assert len(frags) == 1
Green. Commit: explosion_at creates 5 Fragments, one VFragment, one GFragment, in random order.
Reflection
I want to sit and marvel at how nicely that went, just bask in the pleasure for a moment. We enhanced our one test slowly, first checking how many fragments overall, then how many of the exiting type, and then finally how many of each type.
Interleaved with testing, we refactored the target method explostion_at
so that it could accept parameters including the type of fragment desired. Each change was done incrementally, with all the tests running expect the final assertion, which was whatever we were then working on. We wind up here, which is nearly good:
def explosion_at(self, position):
fragment_classes = [VFragment, GFragment, Fragment, Fragment, Fragment, Fragment, Fragment]
random.shuffle(fragment_classes)
how_many = len(fragment_classes)
for i in range(how_many):
fragment_class = fragment_classes[i]
base_direction = 360 * i / how_many
self.make_fragment(base_direction, fragment_class, position)
def make_fragment(self, base_direction, fragment_class, position):
twiddle = random.randrange(-20, 20)
fragment = fragment_class(position=position, angle=base_direction + twiddle)
self.flyers.append(fragment)
I say nearly because I think the order of parameters in make_fragment
should be better. Python will fix this:
def explosion_at(self, position):
fragment_classes = [VFragment, GFragment, Fragment, Fragment, Fragment, Fragment, Fragment]
random.shuffle(fragment_classes)
how_many = len(fragment_classes)
for i in range(how_many):
fragment_class = fragment_classes[i]
base_direction = 360 * i / how_many
self.make_fragment(fragment_class, position, base_direction)
def make_fragment(self, fragment_class, position, base_direction):
twiddle = random.randrange(-20, 20)
fragment = fragment_class(position=position, angle=base_direction + twiddle)
self.flyers.append(fragment)
Better. Commit: Change make_fragment signature for more logical order of parameters.
It all went nicely, snick snick snick. The game works as before, except that there are seven line segments in the explosion rather than five. We are about to fix that.
I want the VFragment to be a small v-shaped two-line thing, and for now, the GFragment should draw a circle as a placeholder. Our single lines are going from -L to L, for L between 6 and 9 inclusive. I draw a v-shape on paper and decide that I will try a line from (-7, 4) to (7, 0), and then to (-7, -4). I’ll use pygame.draw.lines
.
My eyes tell me that a width of five looks better than four, so I wind up with this:
class VFragment:
def draw(self, screen):
v_shape = [Vector2(-7, 5), Vector2(7, 0), Vector2(-7, -5)]
points = [p.rotate(self.theta) + self.position for p in v_shape]
pygame.draw.lines(screen, "white", False, points, 3)
Shall I explain that? I shall. I start with my base points, and in the list comprehension rotate the base by the current theta
and then add the current position
. Then we just draw them. Which reminds me to change the color of the other fragments to “white”, for now. We still have that fading thing to do as well.
Let me draw the circle in GFragment and then I’ll make a short video for you. I went a bit beyond the circle. Here’s the explosion now:
Commit: G Fragment done and working. Code a bit naff.
Here’s the completely ad-hoc code for the astronaut:
def draw(self, screen):
head_off = Vector2(0,16+8).rotate(self.theta)
pygame.draw.circle(screen, "white", self.position + head_off, 8, 2)
body_top = Vector2(0, 16).rotate(self.theta)
body_bottom = Vector2(0, 2).rotate(self.theta)
pygame.draw.line(screen, "white", body_top+self.position, body_bottom+self.position, 3)
leg_left = Vector2(-5, -16).rotate(self.theta)
pygame.draw.line(screen, "white", leg_left+self.position, body_bottom+self.position, 3)
leg_right = Vector2(5, -16).rotate(self.theta)
pygame.draw.line(screen, "white", leg_right+self.position, body_bottom+self.position, 3)
arm_left = Vector2(-9, 10).rotate(self.theta)
arm_right = Vector2(9, 10).rotate(self.theta)
pygame.draw.line(screen, "white", arm_right+self.position, arm_left+self.position, 3)
I’m not proud of that but it does the job. We could remove some of those self
references with a couple of Extract Variable:
def draw(self, screen):
theta = self.theta
head_off = Vector2(0,16+8).rotate(theta)
position = self.position
pygame.draw.circle(screen, "white", position + head_off, 8, 2)
body_top = Vector2(0, 16).rotate(theta)
body_bottom = Vector2(0, 2).rotate(theta)
pygame.draw.line(screen, "white", body_top + position, body_bottom + position, 3)
leg_left = Vector2(-5, -16).rotate(theta)
pygame.draw.line(screen, "white", leg_left + position, body_bottom + position, 3)
leg_right = Vector2(5, -16).rotate(theta)
pygame.draw.line(screen, "white", leg_right + position, body_bottom + position, 3)
arm_left = Vector2(-9, 10).rotate(theta)
arm_right = Vector2(9, 10).rotate(theta)
pygame.draw.line(screen, "white", arm_right + position, arm_left + position, 3)
Perhaps that could be cleaned up a bit. I’ll look at that next time.
Summary
Now you see what I mean by “WAY over the top”, but it only took a couple of hours counting the article and I think it’s a nice little surprise to see the astronaut spinning off into space when you blow up your ship. Probably surprises the astronaut as well.
All this went in very smoothly, due in great part to the tests, although of course the drawing was all hand-crafted with the help of some sketches on graph paper. There was no debugging, everything worked as intended, again due to the tests and to taking very small steps.
I am pleased and amused … and that’s the point, isn’t it?
See you next time!