Python Asteroids on GitHub

I’m just going to look at random bits of code and see what they suggest to me. Watch me if you care to.

In ShotOptimizer, I find this:

class 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

This makes me think at least two things:

First, this method is too complicated. Second, I think we should be using the solution, not the velocity and start members in our actual code. Here’s what I mean:

class Gunner:
    @staticmethod
    def create_optimal_missile(fleets, saucer, ship):
        target_solution = ShotOptimizer(saucer, ship)
        fleets.append(Missile.from_saucer(target_solution.start, target_solution.velocity))

    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)

The unoptimized case just creates a FiringSolution and uses it. Since FiringSolution is a simpler object, I think we should use it above, in the optimal case. Like this:

class Gunner:
    @staticmethod
    def create_optimal_missile(fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).firing_solution
        fleets.append(Missile.from_saucer(solution.start, solution.velocity))

    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)

class 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
        self.firing_solution = FiringSolution(target_position, shooter_position, safe_distance, speed_adjustment)

We can commit that. Commit: refactor to use FiringSolution for both aimed and random missiles.

Now I think we can improve the Gunner a bit. First, extract variable to make the code more visibly alike:

    @staticmethod
    def create_optimal_missile(fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).firing_solution
        missile = Missile.from_saucer(solution.start, solution.velocity)
        fleets.append(missile)

    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)

Extract method to eliminate duplication.

    @staticmethod
    def create_optimal_missile(fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).firing_solution
        Gunner.fire_using_solution(solution, fleets)

    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)
        Gunner.fire_using_solution(solution, fleets)

    @staticmethod
    def fire_using_solution(solution, fleets):
        missile = Missile.from_saucer(solution.start, solution.velocity)
        fleets.append(missile)

The fact that that is a static method makes us wonder where it would better reside.

What about FiringSolution? What if FiringSolution would return the missile, all nicely programmed?

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

    def saucer_missile(self):
        return Missile.from_saucer(self.start, self.velocity)

I should have committed a time or two already. Too late now, but we’re nearly to another good place.

class Gunner:
    @staticmethod
    def fire_using_solution(solution, fleets):
        missile = solution.saucer_missile()
        fleets.append(missile)

Inline the temp.

class Gunner:
    @staticmethod
    def create_optimal_missile(fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).firing_solution
        Gunner.fire_using_solution(solution, fleets)

    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)
        Gunner.fire_using_solution(solution, fleets)

    @staticmethod
    def fire_using_solution(solution, fleets):
        fleets.append(solution.saucer_missile())

No, undo that. I don’t like the static thing there.

I go clear back to here:

    @staticmethod
    def create_optimal_missile(fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).firing_solution
        missile = Missile.from_saucer(solution.start, solution.velocity)
        fleets.append(missile)

    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)

We need to make optimal not be static.

    def create_optimal_missile(self, fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).firing_solution
        missile = Missile.from_saucer(solution.start, solution.velocity)
        fleets.append(missile)

    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 am not inclined to commit, because I am not sure I’m going to like this, having not liked what I did a moment ago. This time let’s get the missile from the solution.

    def create_optimal_missile(self, fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).firing_solution
        missile = solution.saucer_missile()
        fleets.append(missile)

    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 = solution.saucer_missile()
        fleets.append(missile)

Now inline missile.

    def create_optimal_missile(self, fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).firing_solution
        fleets.append(solution.saucer_missile())

    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)
        fleets.append(solution.saucer_missile())

I like this well enough to commit: refactoring. Maybe needed a better comment.

I don’t like the fact that create_optimal_missile could be static. It doesn’t care about anything in the Gunner. With our newly increased sensitivity to such things, we take it as a sign that maybe responsibilities aren’t quite right.

Let’s back away and look at a slightly bigger picture:

    def fire_available_missile(self, chance, fleets, saucer, ship_or_none):
        if ship_or_none and self.should_target(chance, saucer):
            self.create_optimal_missile(fleets, saucer, ship_or_none)
        else:
            self.create_random_missile(fleets, saucer)

    @staticmethod
    def should_target(chance, saucer):
        return saucer.always_target or chance < u.SAUCER_TARGETING_FRACTION

    def create_random_missile(self, fleets, saucer):
        target = self.random_position()
        self.create_unoptimized_missile(saucer.position, target, fleets)

    def create_optimal_missile(self, fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).firing_solution
        fleets.append(solution.saucer_missile())

    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)
        fleets.append(solution.saucer_missile())

Now, it seems to me that part of the issue is that optimized missiles, which maybe should be called targeted as opposed to random, need info about the ship, but random missiles do not.

What if ShotOptimizer, perhaps renamed, had two methods, targeted_solution and random_solution?

Then both methods could call ShotOptimizer, which would be more balanced. Let’s see if we can do that.

Let’s first work toward a new method, targeted_solution, in property form.

class ShotOptimizer:
    def __init__(self, saucer, ship):
        self.saucer = saucer
        self.ship = ship
        self.create_targeted_solution(saucer, ship)

    def create_targeted_solution(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
        return FiringSolution(target_position, shooter_position, safe_distance, speed_adjustment)
    @property
    def targeted_solution(self):
        return self.create_targeted_solution(self.saucer, self.ship)

This breaks many tests, but the fix should be just this:

    def create_optimal_missile(self, fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).targeted_solution
        fleets.append(solution.saucer_missile())

We are green. Now let’s do the random one.

    @property
    def random_solution(self):
        return FiringSolution(self.random_position(), self.saucer.position, self.saucer.missile_head_start, 1)

    def random_position(self):
        return Vector2(self.random_coordinate(), self.random_coordinate())

    @staticmethod
    def random_coordinate():
        return random.randrange(0, u.SCREEN_SIZE)

Now I can use that in Gunner:

    def fire_available_missile(self, chance, fleets, saucer, ship_or_none):
        if ship_or_none and self.should_target(chance, saucer):
            self.create_optimal_missile(fleets, saucer, ship_or_none)
        else:
            self.create_random_missile(fleets, saucer, ship_or_none)

    def create_random_missile(self, fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).random_solution
        fleets.append(solution.saucer_missile())

    def create_optimal_missile(self, fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).targeted_solution
        fleets.append(solution.saucer_missile())

This breaks some tests that are using the create_unoptimized_missile method.

Lost the thread. Step was too big for some reason.

Back to here:

    def create_optimal_missile(self, fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).targeted_solution
        fleets.append(solution.saucer_missile())

    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)
        fleets.append(solution.saucer_missile())

In ShotOptimizer, with a little refactoring, we’re here:

    def __init__(self, saucer, ship):
        self.saucer = saucer
        self.ship = ship

    @property
    def targeted_solution(self):
        shooter_position = self.saucer.position
        best_target_position = self.closest_aiming_point(shooter_position, self.ship.position, u.SCREEN_SIZE)
        vector_to_target = best_target_position - shooter_position
        safe_distance = self.saucer.missile_head_start
        aim_time, speed_adjustment = self.optimal_shot(vector_to_target, self.ship.velocity, safe_distance)
        target_position = best_target_position + self.ship.velocity * aim_time
        return FiringSolution(target_position, shooter_position, safe_distance, speed_adjustment)

    @property
    def random_solution(self):
        return FiringSolution(self.random_position(), self.saucer.position, self.saucer.missile_head_start, 1)

    def random_position(self):
        return Vector2(self.random_coordinate(), self.random_coordinate())

The random_solution is unused but I am confident in it.

We need to get it in use, but without breaking any tests. Here we go:

class Gunner:
    def fire_available_missile(self, chance, fleets, saucer, ship_or_none):
        if ship_or_none and self.should_target(chance, saucer):
            self.create_optimal_missile(fleets, saucer, ship_or_none)
        else:
            self.create_random_missile(fleets, saucer, ship_or_none)

class ShotOptimizer:
    def __init__(self, saucer, ship):
        self.saucer = saucer
        self.ship = ship

    @property
    def targeted_solution(self):
        if not self.ship:
            return self.random_solution
        shooter_position = self.saucer.position
        best_target_position = self.closest_aiming_point(shooter_position, self.ship.position, u.SCREEN_SIZE)
        vector_to_target = best_target_position - shooter_position
        safe_distance = self.saucer.missile_head_start
        aim_time, speed_adjustment = self.optimal_shot(vector_to_target, self.ship.velocity, safe_distance)
        target_position = best_target_position + self.ship.velocity * aim_time
        return FiringSolution(target_position, shooter_position, safe_distance, speed_adjustment)

    @property
    def random_solution(self):
        return FiringSolution(self.random_position(), self.saucer.position, self.saucer.missile_head_start, 1)

This is passing all the tests and working in the game. The check for ship is due to the fact that we have two paths to random missile, and in one of them there is no ship.

We could, and probably should avoid that None, providing a dummy ship or some such thing.

I suspect we’re not quite where things are in balance yet.

There are methods in Gunner that can be removed, and I think some have tests.

I have two tests on the old unoptimized one.

Curiously, I find that the tests are rather suspicious but I fix them up. We’re green and I am of a mind to commit: Refactored to Gunner -> ShotOptimizer -> FiringSolution -> Missile.

Let’s review some of the good bits. Now Gunner is down to this:

class Gunner:
    def __init__(self, saucer_radius=20):
        self._timer = Timer(u.SAUCER_MISSILE_DELAY)

    def fire(self, delta_time, saucer, ship_or_none: Ship | None, fleets):
        self._timer.tick(delta_time, self.fire_if_missile_available, saucer, ship_or_none, fleets)

    def fire_if_missile_available(self, saucer, ship_or_none, fleets):
        if did_we_fire := saucer.missile_tally < u.SAUCER_MISSILE_LIMIT:
            chance = random.random()
            self.fire_available_missile(chance, fleets, saucer, ship_or_none)
        return did_we_fire

    def fire_available_missile(self, chance, fleets, saucer, ship_or_none):
        if ship_or_none and self.should_target(chance, saucer):
            self.create_optimal_missile(fleets, saucer, ship_or_none)
        else:
            self.create_random_missile(fleets, saucer, ship_or_none)

    @staticmethod
    def should_target(chance, saucer):
        return saucer.always_target or chance < u.SAUCER_TARGETING_FRACTION

    @staticmethod
    def create_random_missile(fleets, saucer, ship_or_none):
        solution = ShotOptimizer(saucer, ship_or_none).random_solution
        fleets.append(solution.saucer_missile())

    @staticmethod
    def create_optimal_missile(fleets, saucer, ship):
        solution = ShotOptimizer(saucer, ship).targeted_solution
        fleets.append(solution.saucer_missile())

This is much simpler than it was. I think it can be better. Let’s move the random number down in:

        @staticmethod
        def should_target(chance, saucer):
            return saucer.always_target or random.random() < u.SAUCER_TARGETING_FRACTION

Now we can change signature and remove the other roll:

    def fire_if_missile_available(self, saucer, ship_or_none, fleets):
        if did_we_fire := saucer.missile_tally < u.SAUCER_MISSILE_LIMIT:
            self.fire_available_missile(fleets, saucer, ship_or_none)
        return did_we_fire

    def fire_available_missile(self, fleets, saucer, ship_or_none):
        if ship_or_none and self.should_target(saucer):
            self.create_optimal_missile(fleets, saucer, ship_or_none)
        else:
            self.create_random_missile(fleets, saucer, ship_or_none)

    @staticmethod
    def should_target(saucer):
        return saucer.always_target or random.random() < u.SAUCER_TARGETING_FRACTION

Commit that: push random number call down.

Let’s inline the two create methods. We either have to make them non-static, or do it by hand. By hand is OK this time, it’s copy-paste.

    def fire_available_missile(self, fleets, saucer, ship_or_none):
        if ship_or_none and self.should_target(saucer):
            solution = ShotOptimizer(saucer, ship_or_none).targeted_solution
            fleets.append(solution.saucer_missile())
        else:
            solution = ShotOptimizer(saucer, ship_or_none).random_solution
            fleets.append(solution.saucer_missile())

And pull out the duplication:

    def fire_available_missile(self, fleets, saucer, ship_or_none):
        if ship_or_none and self.should_target(saucer):
            solution = ShotOptimizer(saucer, ship_or_none).targeted_solution
        else:
            solution = ShotOptimizer(saucer, ship_or_none).random_solution
        fleets.append(solution.saucer_missile())

Unfortunately removing those methods broke a couple of tests. They are easily set right and we can commit: refactor And here’s Gunner now:

class Gunner:
    def __init__(self, saucer_radius=20):
        self._timer = Timer(u.SAUCER_MISSILE_DELAY)

    def fire(self, delta_time, saucer, ship_or_none: Ship | None, fleets):
        self._timer.tick(delta_time, self.fire_if_missile_available, saucer, ship_or_none, fleets)

    def fire_if_missile_available(self, saucer, ship_or_none, fleets):
        if did_we_fire := saucer.missile_tally < u.SAUCER_MISSILE_LIMIT:
            self.fire_available_missile(fleets, saucer, ship_or_none)
        return did_we_fire

    def fire_available_missile(self, fleets, saucer, ship_or_none):
        if ship_or_none and self.should_target(saucer):
            solution = ShotOptimizer(saucer, ship_or_none).targeted_solution
        else:
            solution = ShotOptimizer(saucer, ship_or_none).random_solution
        fleets.append(solution.saucer_missile())

    @staticmethod
    def should_target(saucer):
        return saucer.always_target or random.random() < u.SAUCER_TARGETING_FRACTION

Much shorter and still making perfect sense. I think we’ll break here, review the other classes next time.

Summary

Of course we are gilding the asteroid with all these improvements, but what we’re doing is discovering how to make complex code less complex, confusing code more clear. Our changes this afternoon were simple enough, but were not all done with machine refactorings. Such is the case when we move capability between classes: PyCharm offers little or no support for that. Maybe next year.

A few tests broke, but only because either we had moved a capability to another class, or because we had changed a calling sequence, so the changes were straightforward enough that I didn’t even show them.

The flow of missile creation is now similar in both cases, random or targeted. Which reminds me, we nicely got rid of the awkward naming create_optimal_missile vs create_unoptimized_missile or whatever those methods were. Now we have a targeted_solution and random_solution, which seems to me to make more sense.

The Gunner just decides that it can fire, and chooses random or targeted. It asks the ShotOptimizer to return whichever kind of missile it wants. ShotOptimizer always returns a FiringSolution, whether it runs the complex quadratic code or not. And FiringSolution creates a missile without regard to details, and returns it.

This is the kind of object-oriented solution that I like. Ask an object for something, it asks another, which asks another, and after a while the answer returns.

I think this is nearly good, but we’ll review the classes used by Gunner and see how much better we can make them … in future articles.

See you then!