Python Asteroids on GitHub

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!