Python 157 - Last Night
Last night, I showed my friends two largish methods. Soon, everything was different.
Last night was Tuesday, time for the Friday Night Geeks’ Night Out Zoom Ensemble, where we gather together, dance widdershins around our computers, howl at the moon, and examine code. I happened to mention two methods that I thought were too large and the group deigned to look at them. Part of Gunner, they looked like this:
def create_optimal_missile(self, fleets, saucer, ship):
saucer_position = saucer.position
best_target_position = self.closest_aiming_point(saucer_position, ship.position, u.SCREEN_SIZE)
delta_position = best_target_position - saucer_position
delta_velocity = ship.velocity # we treat saucer as not moving
missile_head_start = 2*self._radius
optimizer = ShotOptimizer(delta_position, delta_velocity, missile_head_start)
future_target_position = best_target_position + delta_velocity * optimizer.aim_time
self.create_adjusted_missile(
missile_head_start,
optimizer.adjustment_ratio,
future_target_position,
saucer_position,
fleets)
@staticmethod
def create_adjusted_missile(missile_head_start, velocity_adjustment, target_position, saucer_position,
fleets):
vector_to_target = target_position - saucer_position
direction_to_target = vector_to_target.normalize()
missile_velocity = u.MISSILE_SPEED * direction_to_target
adjusted_velocity = missile_velocity * velocity_adjustment
offset = missile_head_start * direction_to_target
missile = Missile.from_saucer(saucer_position + offset, adjusted_velocity)
fleets.append(missile)
That’s the code from article 155, where we created the ShotOptimizer.
Well that was then and this is now, and here’s what we’ve got going on today.
@staticmethod
def create_optimal_missile(fleets, saucer, ship):
target_solution = ShotOptimizer(saucer, ship)
fleets.append(Missile.from_saucer(target_solution.start, target_solution.velocity))
What we have here, we have ShotOptimizer’s constructor taking the saucer and ship, and now returning an instance, here called target_solution
, which contains just what we need to create a missile. We created that with a long series of refactoring steps, not entirely machine-supported, but all relatively small. The result is the new improved ShotOptimizer, which looks like this:
class ShotOptimizer:
def __init__(self, saucer, ship):
best_target_position = self.closest_aiming_point(saucer.position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - saucer.position
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, saucer.missile_head_start)
future_target_position = best_target_position + ship.velocity * aim_time
direction_to_target = (future_target_position - saucer.position).normalize()
missile_velocity = u.MISSILE_SPEED * direction_to_target
head_start = saucer.missile_head_start * direction_to_target
self.velocity = missile_velocity * speed_adjustment
self.start = saucer.position + head_start
I don’t consider this to be polished, or even quite finished, but as you can see by the fact that the tests and game are running, it is working just fine. ShotOptimizer now includes all the methods necessary to find the closest aiming point, calculate the elapsed shot time aim_time
, and the speed_adjustment
that is needed for perfect accuracy.
We’ve seen most of this before. Here’s the closest aiming point, now moved over to ShotOptimizer, otherwise pretty much as it always was:
class ShotOptimizer:
def closest_aiming_point(self, shooter_position, target_position, wrap_size):
nearest_x = self.nearest(shooter_position.x, target_position.x, wrap_size)
nearest_y = self.nearest(shooter_position.y, target_position.y, wrap_size)
return Vector2(nearest_x, nearest_y)
@staticmethod
def nearest(shooter_coord, target_coord, screen_size):
# Handy Diagram
# ______|______|______
# T T---S++T
# Central T is too far away.
# We are to his right, so
# we shoot toward the right!
direct_distance = abs(target_coord - shooter_coord)
if direct_distance <= screen_size / 2:
return target_coord
elif shooter_coord > target_coord:
return target_coord + screen_size
else:
return target_coord - screen_size
We’ll glance at the other bit as well:
class ShotOptimizer:
def optimal_shot(self, delta_position, delta_velocity, initial_offset):
aim_time = self.time_to_target(delta_position, delta_velocity)
adjustment_ratio = self.velocity_adjustment(aim_time, initial_offset)
return aim_time, adjustment_ratio
def velocity_adjustment(self, aim_time, initial_offset):
return self.compensate_for_offset(aim_time, initial_offset) if aim_time else 1
@staticmethod
def compensate_for_offset(aim_time, initial_offset):
distance_to_target = aim_time * u.MISSILE_SPEED
adjusted_distance = distance_to_target - initial_offset
return adjusted_distance / distance_to_target
@staticmethod
def time_to_target(delta_position, relative_velocity):
# from https://www.gamedeveloper.com/programming/shooting-a-moving-target#close-modal
# return time for hit or 0
# quadratic
a = relative_velocity.dot(relative_velocity) - u.MISSILE_SPEED*u.MISSILE_SPEED
b = 2 * relative_velocity.dot(delta_position)
c = delta_position.dot(delta_position)
disc = b*b - 4*a*c
if disc < 0:
return 0
divisor = (math.sqrt(disc) - b)
if divisor == 0:
return 0
return 2*c / divisor
If you noticed that the conditional returns from time_to_target
have been refactored, you have been paying way too much attention. Everyone hated what I had before. This is perhaps better.
Again, I do not consider this code polished, perhaps not even good enough. But it is tested.
All is not good news. There was another use of the original code over in Gunner, the creation of the random missile, and it is still there.
class Gunner:
def create_unoptimized_missile(self, from_position, to_position, fleets):
no_adjustment = 1
self.create_adjusted_missile(self._missile_head_start, no_adjustment, to_position, from_position, fleets)
@staticmethod
def create_adjusted_missile(missile_head_start, velocity_adjustment, target_position, saucer_position, fleets):
vector_to_target = target_position - saucer_position
direction_to_target = vector_to_target.normalize()
missile_velocity = u.MISSILE_SPEED * direction_to_target
adjusted_velocity = missile_velocity * velocity_adjustment
head_start = missile_head_start * direction_to_target
missile = Missile.from_saucer(saucer_position + head_start, adjusted_velocity)
fleets.append(missile)
This is still using the old create_adjusted_missile
, so it still resides in Gunner, as shown above. Let’s see if we can perhaps get something better. I’ll start by inlining this code: at least we’ll not have that method hanging out there.
We have to make create_adjusted_missile
non-static so that PyCharm will inline it for us.
def create_adjusted_missile(self, missile_head_start, velocity_adjustment, target_position, saucer_position, fleets):
vector_to_target = target_position - saucer_position
direction_to_target = vector_to_target.normalize()
missile_velocity = u.MISSILE_SPEED * direction_to_target
adjusted_velocity = missile_velocity * velocity_adjustment
head_start = missile_head_start * direction_to_target
missile = Missile.from_saucer(saucer_position + head_start, adjusted_velocity)
fleets.append(missile)
And inline:
def create_unoptimized_missile(self, from_position, to_position, fleets):
no_adjustment = 1
vector_to_target = to_position - from_position
direction_to_target = vector_to_target.normalize()
missile_velocity = u.MISSILE_SPEED * direction_to_target
adjusted_velocity = missile_velocity * no_adjustment
head_start = self._missile_head_start * direction_to_target
missile = Missile.from_saucer(from_position + head_start, adjusted_velocity)
fleets.append(missile)
Pretty sure we can eliminate that stuff with the adjusted velocity and no_adjustment
:
First this:
def create_unoptimized_missile(self, from_position, to_position, fleets):
vector_to_target = to_position - from_position
direction_to_target = vector_to_target.normalize()
missile_velocity = u.MISSILE_SPEED * direction_to_target
adjusted_velocity = missile_velocity
head_start = self._missile_head_start * direction_to_target
missile = Missile.from_saucer(from_position + head_start, adjusted_velocity)
fleets.append(missile)
You know what, we could be committing all this. Commit: refactoring create_unoptimized_missile.
Now remove the useless adjusted_velocity and use missile_velocity instead.
def create_unoptimized_missile(self, from_position, to_position, fleets):
vector_to_target = to_position - from_position
direction_to_target = vector_to_target.normalize()
missile_velocity = u.MISSILE_SPEED * direction_to_target
head_start = self._missile_head_start * direction_to_target
missile = Missile.from_saucer(from_position + head_start, missile_velocity)
fleets.append(missile)
Commit: refactoring create_unoptimized_missile.
Now let’s inline missile velocity entirely.
def create_unoptimized_missile(self, from_position, to_position, fleets):
vector_to_target = to_position - from_position
direction_to_target = vector_to_target.normalize()
head_start = self._missile_head_start * direction_to_target
missile = Missile.from_saucer(from_position + head_start, u.MISSILE_SPEED * direction_to_target)
fleets.append(missile)
Commit. Inline vector_to_target. Our programmers understand math that well:
def create_unoptimized_missile(self, from_position, to_position, fleets):
direction_to_target = (to_position - from_position).normalize()
head_start = self._missile_head_start * direction_to_target
missile = Missile.from_saucer(from_position + head_start, u.MISSILE_SPEED * direction_to_target)
fleets.append(missile)
Commit. Rename parameters.
def create_unoptimized_missile(self, shooter_position, target_position, fleets):
direction_to_target = (target_position - shooter_position).normalize()
head_start = self._missile_head_start * direction_to_target
missile = Missile.from_saucer(shooter_position + head_start, u.MISSILE_SPEED * direction_to_target)
fleets.append(missile)
Commit. Now let’s reverse those two multiplications to put the direction first. I think that adds clarity.
def create_unoptimized_missile(self, shooter_position, target_position, fleets):
direction_to_target = (target_position - shooter_position).normalize()
head_start = direction_to_target * self._missile_head_start
missile = Missile.from_saucer(shooter_position + head_start, direction_to_target * u.MISSILE_SPEED )
fleets.append(missile)
Commit. Rename head_start
.
def create_unoptimized_missile(self, shooter_position, target_position, fleets):
direction_to_target = (target_position - shooter_position).normalize()
safety_offset = direction_to_target * self._missile_head_start
missile = Missile.from_saucer(
shooter_position + safety_offset,
direction_to_target * u.MISSILE_SPEED )
fleets.append(missile)
Commit.
Let’s see about using that name over in ShotOptimizer, for consistency. And we should also find some similar code over there.
class ShotOptimizer:
def __init__(self, saucer, ship):
best_target_position = self.closest_aiming_point(saucer.position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - saucer.position
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, saucer.missile_head_start)
future_target_position = best_target_position + ship.velocity * aim_time
direction_to_target = (future_target_position - saucer.position).normalize()
missile_velocity = u.MISSILE_SPEED * direction_to_target
safety_offset = saucer.missile_head_start * direction_to_target
self.velocity = missile_velocity * speed_adjustment
self.start = saucer.position + safety_offset
Those are So Close. There’s that darn speed adjustment again. Let me make the similarity more clear:
def create_unoptimized_missile(self, shooter_position, target_position, fleets):
direction_to_target = (target_position - shooter_position).normalize()
safety_offset = direction_to_target * self._missile_head_start
velocity = direction_to_target * u.MISSILE_SPEED
start = shooter_position + safety_offset
missile = Missile.from_saucer(start, velocity)
fleets.append(missile)
class ShotOptimizer:
def __init__(self, saucer, ship):
best_target_position = self.closest_aiming_point(saucer.position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - saucer.position
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, saucer.missile_head_start)
future_target_position = best_target_position + ship.velocity * aim_time
direction_to_target = (future_target_position - saucer.position).normalize()
safety_offset = direction_to_target * saucer.missile_head_start
velocity = direction_to_target * u.MISSILE_SPEED
self.velocity = velocity * speed_adjustment
self.start = saucer.position + safety_offset
I really want to fold these two together better. Let’s commit this. Now, what if there was a new object, a FiringSolution, containing velocity and start, and the ShotOptimizer had a method to return one.
- Note
- Starting here, I make some progress, then lose my train of thought, roll back, and do again. I am proud to say that I rolled back as soon as I realized I had become confused.
-
You may wish to skim, if you’re not already skimming, but there were some pretty sensible moves in what follows, so maybe observe the intention and outcome of some of the steps. Just remember that I’m going to roll it back, not because it wasn’t just fine, but because it was too much to keep clear in my head.
Can we extract that common bit from ShotOptimizer and make it a method? Not sure. Rearrange ShopOptimizer a bit:
class ShotOptimizer:
def __init__(self, saucer, ship):
best_target_position = self.closest_aiming_point(saucer.position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - saucer.position
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, saucer.missile_head_start)
future_target_position = best_target_position + ship.velocity * aim_time
direction_to_target = (future_target_position - saucer.position).normalize()
safety_offset = direction_to_target * saucer.missile_head_start
velocity = direction_to_target * u.MISSILE_SPEED
start = saucer.position + safety_offset
self.velocity = velocity * speed_adjustment
self.start = start
Move the adjustment up to the temp:
class ShotOptimizer:
def __init__(self, saucer, ship):
best_target_position = self.closest_aiming_point(saucer.position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - saucer.position
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, saucer.missile_head_start)
future_target_position = best_target_position + ship.velocity * aim_time
direction_to_target = (future_target_position - saucer.position).normalize()
safety_offset = direction_to_target * saucer.missile_head_start
velocity = direction_to_target * u.MISSILE_SPEED * speed_adjustment
start = saucer.position + safety_offset
self.velocity = velocity
self.start = start
Put the adjustment back into unoptimized:
def create_unoptimized_missile(self, shooter_position, target_position, fleets):
speed_adjustment = 1
direction_to_target = (target_position - shooter_position).normalize()
safety_offset = direction_to_target * self._missile_head_start
velocity = direction_to_target * u.MISSILE_SPEED * speed_adjustment
start = shooter_position + safety_offset
missile = Missile.from_saucer(start, velocity)
fleets.append(missile)
Darn. These are so close, but I don’t quite see the bridge. Shall I push them until they are closer?
Let’s change ShotOptimizer’s version and then extract it.
class ShotOptimizer:
def __init__(self, saucer, ship):
best_target_position = self.closest_aiming_point(saucer.position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - saucer.position
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, saucer.missile_head_start)
target_position = best_target_position + ship.velocity * aim_time
shooter_position = saucer.position
direction_to_target = (target_position - shooter_position).normalize()
safety_offset = direction_to_target * saucer.missile_head_start
velocity = direction_to_target * u.MISSILE_SPEED * speed_adjustment
start = saucer.position + safety_offset
self.velocity = velocity
self.start = start
First, extract those four lines to a method and see how they look. …
- Note
- Here, I realize that with too many thoughts in the air, I’ve lost the picture. Be grateful that I’m not an air traffic controller. I wisely roll back.
I’ve lost the thread. Change has taken too long. Roll back.
OK, let’s look again, but first, a break.
Break …
- Note
- This, too, was a good idea. It gives me time to settle down, and to forget just enough so that next time I can be more into the code and less into my head. Very clever of me. Also I got a nice iced chai out of the break.
Here are our two similar code patches:
class Gunner:
def create_unoptimized_missile(self, shooter_position, target_position, fleets):
direction_to_target = (target_position - shooter_position).normalize()
safety_offset = direction_to_target * self._missile_head_start
velocity = direction_to_target * u.MISSILE_SPEED
start = shooter_position + safety_offset
missile = Missile.from_saucer(start, velocity)
fleets.append(missile)
def __init__(self, saucer, ship):
best_target_position = self.closest_aiming_point(saucer.position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - saucer.position
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, saucer.missile_head_start)
future_target_position = best_target_position + ship.velocity * aim_time
direction_to_target = (future_target_position - saucer.position).normalize()
safety_offset = direction_to_target * saucer.missile_head_start
velocity = direction_to_target * u.MISSILE_SPEED
self.velocity = velocity * speed_adjustment
self.start = saucer.position + safety_offset
Again, let’s try to make them more similar. I’ll commit more frequently: it can do no harm, and next time I lose the thread, if I do, we’ll have a closer point to roll back to.
def create_unoptimized_missile(self, shooter_position, target_position, fleets):
safe_distance = self._missile_head_start
direction_to_target = (target_position - shooter_position).normalize()
safety_offset = direction_to_target * safe_distance
velocity = direction_to_target * u.MISSILE_SPEED
start = shooter_position + safety_offset
missile = Missile.from_saucer(start, velocity)
fleets.append(missile)
def __init__(self, saucer, ship):
best_target_position = self.closest_aiming_point(saucer.position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - saucer.position
safe_distance = saucer.missile_head_start
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, safe_distance)
future_target_position = best_target_position + ship.velocity * aim_time
direction_to_target = (future_target_position - saucer.position).normalize()
safety_offset = direction_to_target * safe_distance
velocity = direction_to_target * u.MISSILE_SPEED
start = saucer.position + safety_offset
self.velocity = velocity * speed_adjustment
self.start = start
- Note
-
I should mention that as I do this kind of thing, often I get the two chunks looking pretty close, and decide that I can do the extract that I need. I’ve found that although it takes a few more steps, I do best to get the two chunks looking Very Much Alike. And that’s what I’m doing here.
Commit: refactoring toward duplication.
Move the adjustment up
def __init__(self, saucer, ship):
best_target_position = self.closest_aiming_point(saucer.position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - saucer.position
safe_distance = saucer.missile_head_start
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, safe_distance)
future_target_position = best_target_position + ship.velocity * aim_time
direction_to_target = (future_target_position - saucer.position).normalize()
safety_offset = direction_to_target * safe_distance
velocity = direction_to_target * u.MISSILE_SPEED * speed_adjustment
start = saucer.position + safety_offset
self.velocity = velocity
self.start = start
Commit. Add a null adjustment to the Gunner version.
def create_unoptimized_missile(self, shooter_position, target_position, fleets):
safe_distance = self._missile_head_start
speed_adjustment = 1
direction_to_target = (target_position - shooter_position).normalize()
safety_offset = direction_to_target * safe_distance
velocity = direction_to_target * u.MISSILE_SPEED * speed_adjustment
start = shooter_position + safety_offset
missile = Missile.from_saucer(start, velocity)
fleets.append(missile)
In ShotOptimizer, extract the saucer.position
:
def __init__(self, saucer, ship):
shooter_position = saucer.position
best_target_position = self.closest_aiming_point(shooter_position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - shooter_position
safe_distance = saucer.missile_head_start
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, safe_distance)
future_target_position = best_target_position + ship.velocity * aim_time
direction_to_target = (future_target_position - shooter_position).normalize()
safety_offset = direction_to_target * safe_distance
velocity = direction_to_target * u.MISSILE_SPEED * speed_adjustment
start = shooter_position + safety_offset
self.velocity = velocity
Commit. Rename away future
.
def __init__(self, saucer, ship):
shooter_position = saucer.position
best_target_position = self.closest_aiming_point(shooter_position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - shooter_position
safe_distance = saucer.missile_head_start
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, safe_distance)
target_position = best_target_position + ship.velocity * aim_time
direction_to_target = (target_position - shooter_position).normalize()
safety_offset = direction_to_target * safe_distance
velocity = direction_to_target * u.MISSILE_SPEED * speed_adjustment
start = shooter_position + safety_offset
self.velocity = velocity
self.start = start
I think the four-line snippets are now exactly the same:
def create_unoptimized_missile(self, shooter_position, target_position, fleets):
safe_distance = self._missile_head_start
speed_adjustment = 1
direction_to_target = (target_position - shooter_position).normalize()
safety_offset = direction_to_target * safe_distance
velocity = direction_to_target * u.MISSILE_SPEED * speed_adjustment
start = shooter_position + safety_offset
missile = Missile.from_saucer(start, velocity)
fleets.append(missile)
Let’s see if we can extract a method now.
def create_unoptimized_missile(self, shooter_position, target_position, fleets):
safe_distance = self._missile_head_start
speed_adjustment = 1
start, velocity = self.solution(target_position, shooter_position, safe_distance, speed_adjustment)
missile = Missile.from_saucer(start, velocity)
fleets.append(missile)
def solution(self, target_position, shooter_position, safe_distance, speed_adjustment):
direction_to_target = (target_position - shooter_position).normalize()
safety_offset = direction_to_target * safe_distance
velocity = direction_to_target * u.MISSILE_SPEED * speed_adjustment
start = shooter_position + safety_offset
return start, velocity
Now let’s create a little class, FiringSolution, taking those parameters in the constructor and initializing members start and velocity.
class FiringSolution:
def __init__(self, target_position, shooter_position, safe_distance, speed_adjustment):
direction_to_target = (target_position - shooter_position).normalize()
safety_offset = direction_to_target * safe_distance
self.velocity = direction_to_target * u.MISSILE_SPEED * speed_adjustment
self.start = shooter_position + safety_offset
Now let’s use it in Gunner. (I am hopeful but not dead certain that it’ll work for ShotOptimizer also.)
def create_unoptimized_missile(self, shooter_position, target_position, fleets):
safe_distance = self._missile_head_start
speed_adjustment = 1
solution = FiringSolution(target_position, shooter_position, safe_distance, speed_adjustment)
missile = Missile.from_saucer(solution.start, solution.velocity)
fleets.append(missile)
I hope this works, I missed at least one chance to commit above. Tests are green. Test in game for extra confidence. We’re good.
Commit: FiringSolution used in unoptimized missile.
Now use it in ShotOptimizer:
def __init__(self, saucer, ship):
shooter_position = saucer.position
best_target_position = self.closest_aiming_point(shooter_position, ship.position, u.SCREEN_SIZE)
vector_to_target = best_target_position - shooter_position
safe_distance = saucer.missile_head_start
aim_time, speed_adjustment = self.optimal_shot(vector_to_target, ship.velocity, safe_distance)
target_position = best_target_position + ship.velocity * aim_time
solution = FiringSolution(target_position, shooter_position, safe_distance, speed_adjustment)
self.velocity = solution.velocity
self.start = solution.start
We are green and good. Commit: FiringSolution used in optimized and unoptimized saucer missiles.
Now this is getting long, and I’m getting tired, so we’ll stop now. I think the next step will be to ask the ShotOptimizer for a solution and use that rather than the current start
and velocity
members of SO.
But for now … enough. It has been a somewhat stressful day, for non-programming reasons.
Summary
The Zoom team last night got us a decent way along the path to moving more logic from the Gunner to the ShotOptimizer, and left us with a bit of a dangling appendix, in the case of the unoptimized missiles.
There is still a bit of the ShotOptimizer-equivalent code left in Gunner, because there are a couple of tests that need to be redirected, but that should be straightforward.
Then we’ll change things so that the optimizer can provide a FiringSolution, and we’ll generate another FiringSolution directly, and use them similarly. Perhaps we’ll find more refinements as we do that. Probably some renaming. Maybe more, I really don’t know what I’ll see when next I look at this.
For now, a nice result, with better separation of concerns among Saucer, Gunner, ShotOptimizer, and FiringSolution. Not long ago, all that was glommed into Saucer!
See you next time!