Python Asteroids on GitHub

It’s funny how thinking about one thing can give you an idea about another thing. Just a little fun today.

I was musing, half-asleep, about other things that might be extracted from ShipMaker to make its design better. I thought about the safety check, which checks to see that there are no missiles flying, and no saucer flying, and no asteroids within some given range of the center of the screen, before allowing a new Ship to rez. This is intended to give you a fair chance.

One thing that sometimes happens is that there are no asteroids near the center, but there are one or more coming toward the center with great vigor, so that you emerge with no chance to accelerate out of the way.

It popped into my mind that an interesting safety rule would be that all asteroids — or perhaps all asteroids within a given range — must be moving away from the center. And it occurred to me that checking for that is easy:

If we consider the vector a=asteroid.position-u.CENTER, that is the vector from the center to the asteroid. If we consider the asteroid’s velocity, it is moving away from the center if the sign of a.x equals the sign of v.x or the sign of a.y equals the sign of v.y.

Let’s think about it: If you’re up and to the right, your position minus center has +x and +y. If your velocity has the same signs then your x and y will increase, and you’re moving away.

Is it sufficient for just one of the signs to match? I think it is. If your y is moving away from my y, you aren’t coming at me, bro, no matter what your x is doing. Only if both signs differ could you possibly be moving toward me.

Added in Post:
I wonder how we could make this comparison tighter, so that we would only reject asteroids within range that could actually hit us. I’ll reflect on that, but, as you’ll see below, I commit today’s work as an experimental feature.

I want to try this. Let’s consider our little MovableLocation object.

class MovableLocation:
    def __init__(self, position, velocity, size=u.SCREEN_SIZE):
        self.position = position
        self.velocity = velocity
        self.size = size

I wonder what size is doing in there. Weird. Anyway it is used for wrapping around. I think I have tests that use some other number. Weird. Let’s at least rename that to screen_size.

I have some tests for the thing. Let’s test drive:

    def test_moving_away_from_point(self):
        center = u.CENTER
        position = center + Vector2(10, 10)
        velocity = Vector2(1, 1)
        ml = MovableLocation(position, velocity)
        assert ml.moving_away_from(u.CENTER)

My first clumsy implementation is this:

class MovableLocation:
    def moving_away_from(self, vector):
        offset = self.position - vector
        vel = self.velocity
        if offset.x > 0 and vel.x > 0:
            return True
        elif offset.x < 0 and vel.x < -0:
            return True
        elif offset.y > 0 and vel.y > 0:
            return True
        elif offset.y < 0 and vel.y < 0:
            return True
        return False

I think this is actually correct. There are a lot of cases we should check: in principle there are at least eight ways to be moving away from center.

I’m just experimenting here. Let’s see about improving the method:

    def moving_away_from(self, vector):
        offset = self.position - vector
        sx = copysign(1, offset.x)
        sy = copysign(1, offset.y)
        vx = copysign(1, self.velocity.x)
        vy = copysign(1, self.velocity.y)
        return sx == vx or sy == vy

copysign(1, foo) returns 1 if foo is positive, -1 if its negative.

Test runs. Let’s try one more case in the test.

    def test_moving_away_from_point(self):
        center = u.CENTER
        position = center + Vector2(10, 10)
        velocity = Vector2(1, 1)
        ml = MovableLocation(position, velocity)
        assert ml.moving_away_from(u.CENTER)
        m2 = MovableLocation(center + Vector2(10, -10), Vector2(-1, 1))
        assert not m2.moving_away_from(u.CENTER)

Let’s see if we can patch this in. But first, Commit: new method moving_away_from.

In ShipMaker, we do our asteroid check like this:

class ShipMaker(Flyer):
    def interact_with_asteroid(self, asteroid, fleets):
        if asteroid.position.distance_to(u.CENTER) < u.SAFE_EMERGENCE_DISTANCE:
            self._safe_to_emerge = False

As an experiment, let’s start with a very stringent criterion, all asteroids must be moving away from center.

    def interact_with_asteroid(self, asteroid, fleets):
        # if asteroid.position.distance_to(u.CENTER) < u.SAFE_EMERGENCE_DISTANCE:
        #     self._safe_to_emerge = False
        ml = asteroid._location
        if not ml.moving_away_from(u.CENTER):
            self._safe_to_emerge = False

Terribly invasive but we’re just experimenting. I have written a note to improve this code if we retain this experimental feature.

As written this is too stringent: the ship hardly ever finds a moment to rez. What do we really want? If asteroids are close but moving away, we can rez. If they are close and moving toward, we cannot. Let’s try this:

    def interact_with_asteroid(self, asteroid, fleets):
        if asteroid.position.distance_to(u.CENTER) < u.SAFE_EMERGENCE_DISTANCE:
            ml = asteroid._location
            if not ml.moving_away_from(u.CENTER):
                self._safe_to_emerge = False

As written, this actually allows the ship to rez sooner, in the case of asteroids that are near the center but moving away. And in playing, I did see at least one case where the ship rezzed very close to an asteroid but it was moving away.

What could go wrong here is that the asteroid could be almost at center and moving away and we’d rez on top of it. So we do need some minimum distance check. And I also saw one case where an asteroid was outside the radius when I rezzed but so close I couldn’t get away. SAFE_EMERGENCE_DISTANCE is only 100, about 1/10 of the screen. Let’s try this:

    def interact_with_asteroid(self, asteroid, fleets):
        distance = asteroid.position.distance_to(u.CENTER)
        if distance < 25 + asteroid.radius:
            self._safe_to_emerge = False
        elif distance < 2*u.SAFE_EMERGENCE_DISTANCE:
            ml = asteroid._location
            if not ml.moving_away_from(u.CENTER):
                self._safe_to_emerge = False

Now if the asteroid would be hitting us now, we are not safe to emerge. Otherwise if it’s inside twice the safe distance (just multiplied to get a feel for it), we don’t emerge if it’s moving toward us. In play, that’s nearly good, though an asteroid outside the safety margin and moving directly toward center is still pretty dangerous.

I’ll change the margin and remove the multiply and commit this.

    def interact_with_asteroid(self, asteroid, fleets):
        distance = asteroid.position.distance_to(u.CENTER)
        ship_radius = 25
        if distance < ship_radius + asteroid.radius:
            self._safe_to_emerge = False
        elif distance < u.SAFE_EMERGENCE_DISTANCE:
            ml = asteroid._location
            if not ml.moving_away_from(u.CENTER):
                self._safe_to_emerge = False

u.py
SAFE_EMERGENCE_DISTANCE = 200

One test is failing. A test asteroid inside the circle but moving away passed, so I gave it a velocity toward the center and the test passes.

I’m going to commit this, labeled as an experiment: Experimental feature: Ship has larger safe emergence distance and allows asteroids inside the safe zone only if they are moving away.

Summary

I think we have a bit more emergence safety, with a larger safety zone, but we do allow asteroids within the zone, which makes it look more scary. We’ll try the feature on our game testers and see if they notice and if they like or dislike it.

Just a little play. Sometimes we need that. A little bit fast and loose. We probably never need that but we humans are not machines. Well, probably we are machines but we are engineered with a lot of built-in variability.

See you next time!