Python Asteroids on GitHub

The new targeting code seems to work, though I could not derive it. I want to refactor so that it makes more sense, and I have an idea for a bit more testing.

The current code calculates the aiming point unconditionally on every call toe fire, which is every 60th of a second, despite that we only fire at most every half second.

class Gunner:
    def fire(self, delta_time, saucer, ship_or_none: Ship | None, fleets):
        if not ship_or_none:
            ship_position = self.random_position()
        else:
            ship_position = self.choose_aiming_point(saucer, ship_or_none)
        self._timer.tick(delta_time, self.fire_missile, saucer, ship_position, fleets)

Time was, we only computed ship_position as ship.position or random_positon(), which wasn’t exactly ideal but perhaps forgivable. But now that we do choose_aiming_point, which solves a quadratic, it seems like too much. All we really need to do is to push that if block down, and pass in ship_or_none instead of position to the timer.

First extract the block:

    def fire(self, delta_time, saucer, ship_or_none: Ship | None, fleets):
        ship_position = self.select_aiming_point(saucer, ship_or_none)
        self._timer.tick(delta_time, self.fire_missile, saucer, ship_position, fleets)

    def select_aiming_point(self, saucer, ship_or_none):
        if not ship_or_none:
            ship_position = self.random_position()
        else:
            ship_position = self.choose_aiming_point(saucer, ship_or_none)
        return ship_position

Refine that extracted method to be more like the way I like them:

    def select_aiming_point(self, saucer, ship_or_none):
        if ship_or_none:
            return self.choose_aiming_point(saucer, ship_or_none)
        else:
            return self.random_position()

We could be committing this. In the spirit of small steps, let’s do that. Commit: refactoring step.

This next bit, I’ll have to do manually. I’ll pass ship_or_none instead of ship_position to the timer tick and call the select from there.

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

This breaks things, of course. Then:

    def fire_missile(self, saucer, ship_or_none, fleets):
        ship_position = self.select_aiming_point(saucer, ship_or_none)
        if saucer.missile_tally < u.SAUCER_MISSILE_LIMIT:
            self.select_missile(random.random(), fleets, saucer, ship_position)

And we are back to green. Let’s test in the game. Seems to work. I do think that the faster missile velocity gave better (more deadly) results. We’ll leave the speed and timing as they are, for now, but might want to tune things at some point.

Testing

I did have an idea for testing: Given aiming_time, the missile and the ship should arrive at the same point at that time. Seems we could test that for some arbitrary numbers.

After some delay … I really like it when a test teaches me something. Here it is, in a form I like for now:

    def test_hits_target(self):
        fleets = Fleets()
        fi = FI(fleets)
        gunner = Gunner(10)
        ship = Ship(Vector2(100, 100))
        ship._location.velocity = Vector2(37, 59)
        saucer = Saucer(1)
        saucer.move_to(Vector2(19, 43))
        relative_position = Vector2(100, 100) - Vector2(19, 43)
        relative_velocity = Vector2(37, 59)
        time = gunner.time_to_target(relative_position, relative_velocity)
        assert time
        gunner.fire_missile(saucer, ship, fleets)
        assert fi.missiles
        missile = fi.missiles[0]
        missile_pos = missile.position + missile.velocity_testing_only*time
        ship_pos = ship.position + ship.velocity*time
        assert missile_pos.distance_to(ship_pos) == 0

We set up a Fleets, a ship, and a saucer with some fairly random values. Then we get the time to target from that function, and then we actually fire a missile.

Then we assert that the two positions are equal. They are not. Their distance apart? 19.999999999999986. The Saucer radial offset? 2 times radius, or 20. Change the test before I talk about it further.

        assert missile_pos.distance_to(ship_pos) == pytest.approx(2*saucer._radius)

I mentioned this this morning, and probably in the past. The targeting we use is calculating from the saucer’s center, and we start the missile at 2*radius so that we don’t blow up the saucer every time we fire. So the missile is inaccurate to that degree.

I am not sure that will always be the difference but intuitively it seems that the missile will be 20 units past the ship at the aiming time.

A quick bit of hackery tells me that if I can get that radius thing resolved, the saucer is even more demonic. As things stand now, it still misses too often, though is often quite nearly hitting. Kind of what you’d expect. I’ll try to devise a better fix that doesn’t involve type-checking the missiles.

Commit: refactoring step.

Let’s wrap up, I am tired.

Summary

The refactoring has made the “long” calculation only occur when we’re actually firing. I’m not sure things are broken out quite as nicely for testing as they might be. But we do have a new test that at least sketches the idea that, if the firing solution is correct, the missile and ship will arrive at the same point at the aiming time. So that concept is solid, though the test is rather arcane.

I’m not certain how to adjust for the saucer radius in the equation. Making the saucer impervious to its own missiles is pretty easy, but involves a type check, or a thinly-disguised one.

But the code is a bit better, and I’m going back to reading my book.

See you next time!