GitHub Decentralized Repo
GitHub Centralized Repo
GitHub Flat Repo

Let’s just do some little things. Rotate the asteroids, maybe add in the other shapes. But no: A refactoring goes awry. Or agley, I don’t know.

I just want to while away some moments. Here’s an easy one to start: when activating an asteroid, set its rotation to a random angle so that they don’t all look alike. I don’t know if the original game did that or not. But for us, it’s easy:

fun activateAsteroidAtEdge() {
    val asteroids = SpaceObjects.filter { it.type == SpaceObjectType.ASTEROID }
    val available = asteroids.firstOrNull { !it.active }
    if (available != null) {
        available.scale = 4.0
        available.active = true
        available.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
        available.velocity = randomVelocity()
    }
}

That becomes:

fun activateAsteroidAtEdge() {
    val asteroids = SpaceObjects.filter { it.type == SpaceObjectType.ASTEROID }
    val available = asteroids.firstOrNull { !it.active }
    if (available != null) {
        available.scale = 4.0
        available.angle = Random.nextDouble(360.0)
        available.active = true
        available.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
        available.velocity = randomVelocity()
    }
}

I see no need to test that. We’ll observe the screen … and I notice low variability. As asteroids split they seem to be getting similar angles. So much for “easy”.

I quickly find the reason: there are multiple places doing “the same thing”:

fun activateAsteroidAtEdge() {
    val asteroids = SpaceObjects.filter { it.type == SpaceObjectType.ASTEROID }
    val available = asteroids.firstOrNull { !it.active }
    if (available != null) {
        available.scale = 4.0
        available.angle = Random.nextDouble(360.0) // new
        available.active = true
        available.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
        available.velocity = randomVelocity()
    }
}

private fun spawnNewAsteroid(asteroid: SpaceObject) {
    val newOne: SpaceObject? = SpaceObjects.firstOrNull { it.type == SpaceObjectType.ASTEROID && ! it.active }
    if (newOne != null) {
        newOne.position = asteroid.position
        newOne.scale = asteroid.scale
        newOne.angle = Random.nextDouble(360.0) // new
        newOne.active = true
        val angle = Random.nextDouble(90.0, 270.0)
        newOne.velocity = asteroid.velocity.rotate(angle)
    }

private fun splitOrKillAsteroid(asteroid: SpaceObject) {
    if (asteroid.scale > 1) {
        asteroid.scale /= 2
        asteroid.angle = Random.nextDouble(360.0)
        asteroid.velocity = randomVelocity()
        spawnNewAsteroid(asteroid)
    } else deactivate(asteroid)
}

You see, grasshopper?

That right there is the reason why we try so hard to eliminate duplication. When a change is needed, we’d like to make that change in just one place, not several. If it has to be made in more than one place, we’re likely to miss one. Or two. Or more.

Let’s fix that up. The first two examples above are begging to have a function extracted. First I want to make those two more alike:

fun activateAsteroidAtEdge() {
    val asteroids = SpaceObjects.filter { it.type == SpaceObjectType.ASTEROID }
    val available = asteroids.firstOrNull { !it.active }
    if (available != null) {
        available.angle = Random.nextDouble(360.0)
        available.active = true
        available.scale = 4.0
        available.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
        available.velocity = randomVelocity()
    }
}

private fun spawnNewAsteroid(asteroid: SpaceObject) {
    val newOne: SpaceObject? = SpaceObjects.firstOrNull { it.type == SpaceObjectType.ASTEROID && ! it.active }
    if (newOne != null) {
        newOne.active = true
        newOne.angle = Random.nextDouble(360.0)
        newOne.position = asteroid.position
        newOne.scale = asteroid.scale
        val velocityDirection = Random.nextDouble(90.0, 270.0)
        newOne.velocity = asteroid.velocity.rotate(velocityDirection)
    }
}

They aren’t quite the same, there’s that difference in the angle. I’m not liking how this is going. Let’s revert and start again.

Let’s examine the cases. We have the activation at edge:

fun activateAsteroidAtEdge() {
    val asteroids = SpaceObjects.filter { it.type == SpaceObjectType.ASTEROID }
    val available = asteroids.firstOrNull { !it.active }
    if (available != null) {
        available.scale = 4.0
        available.active = true
        available.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
        available.velocity = randomVelocity()
    }
}

What’s important here is the starting position, and of course we want to provide for a random angle. Then there’s the adjustment of the existing asteroid in a split:

private fun splitOrKillAsteroid(asteroid: SpaceObject) {
    if (asteroid.scale > 1) {
        asteroid.scale /= 2
        asteroid.velocity = randomVelocity()
        spawnNewAsteroid(asteroid)
    } else deactivate(asteroid)
}

Here we keep the position, and don’t really need to activate, but we would like a random rotation and direction. Then the second, split, asteroid:

private fun spawnNewAsteroid(asteroid: SpaceObject) {
    val newOne: SpaceObject? = SpaceObjects.firstOrNull { it.type == SpaceObjectType.ASTEROID && ! it.active }
    if (newOne != null) {
        newOne.position = asteroid.position
        newOne.scale = asteroid.scale
        newOne.active = true
        val angle = Random.nextDouble(90.0, 270.0)
        newOne.velocity = asteroid.velocity.rotate(angle)
    }
}

Here we are basing the asteroid’s velocity on the other one, so that they tend to separate.

I don’t see quite how to resolve the cases:

  1. Position on edge, scale 4, random rotation angle, random direction;
  2. Position unchanged, scale down by half, random rotation angle, random direction;
  3. Position set (same as other), scale same as other, random rotation angle, related direction.

Let’s extract just this much:

fun activateAsteroidAtEdge() {
    val asteroids = SpaceObjects.filter { it.type == SpaceObjectType.ASTEROID }
    val available = asteroids.firstOrNull { !it.active }
    if (available != null) {
        available.scale = 4.0
        available.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
        available.velocity = randomVelocity()
        activateAsteroid(available)
    }
}

private fun activateAsteroid(asteroid: SpaceObject) {
    asteroid.angle = Random.nextDouble(360.0)
    asteroid.active = true
}

Now let’s call that from the other two:

private fun spawnNewAsteroid(asteroid: SpaceObject) {
    val newOne: SpaceObject? = SpaceObjects.firstOrNull { it.type == SpaceObjectType.ASTEROID && ! it.active }
    if (newOne != null) {
        newOne.position = asteroid.position
        newOne.scale = asteroid.scale
        val velocityDirection = Random.nextDouble(90.0, 270.0)
        newOne.velocity = asteroid.velocity.rotate(velocityDirection)
        activateAsteroid(newOne)
    }
}

This is enough to test for a random look. Looks good so far. Now the other half of the split:

private fun splitOrKillAsteroid(asteroid: SpaceObject) {
    if (asteroid.scale > 1) {
        asteroid.scale /= 2
        asteroid.velocity = randomVelocity()
        activateAsteroid(asteroid)
        spawnNewAsteroid(asteroid)
    } else deactivate(asteroid)
}

The only difference should be that now both halves of a split asteroid get new angles. Hardly visible in all the excitement. They do.

Now we examine our three cases together:

private fun splitOrKillAsteroid(asteroid: SpaceObject) {
    if (asteroid.scale > 1) {
        asteroid.scale /= 2
        asteroid.velocity = randomVelocity()
        activateAsteroid(asteroid)
        spawnNewAsteroid(asteroid)
    } else deactivate(asteroid)
}

private fun spawnNewAsteroid(asteroid: SpaceObject) {
    val newOne: SpaceObject? = SpaceObjects.firstOrNull { it.type == SpaceObjectType.ASTEROID && ! it.active }
    if (newOne != null) {
        newOne.position = asteroid.position
        newOne.scale = asteroid.scale
        val velocityDirection = Random.nextDouble(90.0, 270.0)
        newOne.velocity = asteroid.velocity.rotate(velocityDirection)
        activateAsteroid(newOne)
    }
}

fun activateAsteroidAtEdge() {
    val asteroids = SpaceObjects.filter { it.type == SpaceObjectType.ASTEROID }
    val available = asteroids.firstOrNull { !it.active }
    if (available != null) {
        available.scale = 4.0
        available.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
        available.velocity = randomVelocity()
        activateAsteroid(available)
    }
}

private fun activateAsteroid(asteroid: SpaceObject) {
    asteroid.angle = Random.nextDouble(360.0)
    asteroid.active = true
}

All three want to provide the scale. Let’s change addAsteroid to accept scale:

fun activateAsteroidAtEdge() {
    val asteroids = SpaceObjects.filter { it.type == SpaceObjectType.ASTEROID }
    val available = asteroids.firstOrNull { !it.active }
    if (available != null) {
        available.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
        available.velocity = randomVelocity()
        activateAsteroid(available,4.0)
    }
}

private fun activateAsteroid(asteroid: SpaceObject, scale: Double) {
    asteroid.scale = scale
    asteroid.angle = Random.nextDouble(360.0)
    asteroid.active = true
}

private fun splitOrKillAsteroid(asteroid: SpaceObject) {
    if (asteroid.scale > 1) {
        asteroid.velocity = randomVelocity()
        activateAsteroid(asteroid,asteroid.scale / 2)
        spawnNewAsteroid(asteroid)
    } else deactivate(asteroid)
}

private fun spawnNewAsteroid(asteroid: SpaceObject) {
    val newOne: SpaceObject? = SpaceObjects.firstOrNull { it.type == SpaceObjectType.ASTEROID && ! it.active }
    if (newOne != null) {
        newOne.position = asteroid.position
        val velocityDirection = Random.nextDouble(90.0, 270.0)
        newOne.velocity = asteroid.velocity.rotate(velocityDirection)
        activateAsteroid(newOne,asteroid.scale)
    }
}

Now we have the matter of position and velocity. Let’s add position to our base function.

fun activateAsteroidAtEdge() {
    val asteroids = SpaceObjects.filter { it.type == SpaceObjectType.ASTEROID }
    val available = asteroids.firstOrNull { !it.active }
    if (available != null) {
        available.velocity = randomVelocity()
        val edgePosition = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
        activateAsteroid(available, 4.0,, edgePosition)
    }
}

private fun activateAsteroid(asteroid: SpaceObject, scale: Double, position: Vector2) {
    asteroid.position = position
    asteroid.scale = scale
    asteroid.angle = Random.nextDouble(360.0)
    asteroid.active = true
}

private fun splitOrKillAsteroid(asteroid: SpaceObject) {
    if (asteroid.scale > 1) {
        asteroid.velocity = randomVelocity()
        activateAsteroid(asteroid, asteroid.scale / 2,asteroid.position)
        spawnNewAsteroid(asteroid)
    } else deactivate(asteroid)
}

private fun spawnNewAsteroid(asteroid: SpaceObject) {
    val newOne: SpaceObject? = SpaceObjects.firstOrNull { it.type == SpaceObjectType.ASTEROID && ! it.active }
    if (newOne != null) {
        val velocityDirection = Random.nextDouble(90.0, 270.0)
        newOne.velocity = asteroid.velocity.rotate(velocityDirection)
        activateAsteroid(newOne, asteroid.scale, asteroid.position)
    }
}

We could be committing these, by the way. Let’s add one more parameter to our activate, this time an angle:

fun activateAsteroidAtEdge() {
    val asteroids = SpaceObjects.filter { it.type == SpaceObjectType.ASTEROID }
    val available = asteroids.firstOrNull { !it.active }
    if (available != null) {
        val edgePosition = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
        activateAsteroid(available, 4.0, edgePosition, randomAngle())
    }
}

private fun activateAsteroid(
	asteroid: SpaceObject, 
	scale: Double, position: Vector2, 
	flightAngle: Double) 
{
    asteroid.position = position
    asteroid.scale = scale
    asteroid.velocity = Vector2(U.AsteroidSpeed,0.0).rotate(flightAngle)
    asteroid.angle = randomAngle()
    asteroid.active = true
}

private fun splitOrKillAsteroid(asteroid: SpaceObject) {
    if (asteroid.scale > 1) {
        activateAsteroid(asteroid, asteroid.scale / 2, asteroid.position, randomAngle())
        spawnNewAsteroid(asteroid)
    } else deactivate(asteroid)
}

private fun spawnNewAsteroid(asteroid: SpaceObject) {
    val newOne: SpaceObject? = SpaceObjects.firstOrNull { it.type == SpaceObjectType.ASTEROID && ! it.active }
    if (newOne != null) {
        val velocityAngle = Random.nextDouble(90.0, 270.0)
        activateAsteroid(newOne, asteroid.scale, asteroid.position, velocityAngle)
    }
}

All better, now there’s only one way to do it. Test.

Who’s the grasshopper now, “grasshopper”?

There’s a bug. The spawn angle is not good. Split asteroids tend to move together. I’ve done the second angle wrongly. It should relate to the other angle, not be completely random.

I’m confusing angle with direction. And passing in a new angle will be tricky. First make it work.

private fun spawnNewAsteroid(asteroid: SpaceObject) {
    val newOne: SpaceObject? = SpaceObjects.firstOrNull { it.type == SpaceObjectType.ASTEROID && ! it.active }
    if (newOne != null) {
        val oldAngle = atan2(asteroid.dy, asteroid.dx).asDegrees
        val newAngle = oldAngle + Random.nextDouble(90.0,270.0)
        activateAsteroid(newOne, asteroid.scale, asteroid.position, newAngle)
    }
}

That’s nasty. I think we want velocity after all. Change signature:

private fun activateAsteroid(
	asteroid: SpaceObject, 
	scale: Double, 
	position: Vector2, 
	velocity: Vector2) 
{
    asteroid.position = position
    asteroid.scale = scale
    asteroid.velocity = velocity
    asteroid.angle = randomAngle()
    asteroid.active = true
}

fun activateAsteroidAtEdge() {
    val asteroids = SpaceObjects.filter { it.type == SpaceObjectType.ASTEROID }
    val available = asteroids.firstOrNull { !it.active }
    if (available != null) {
        val edgePosition = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
        activateAsteroid(available, 4.0, edgePosition, randomVelocity())
    }
}

private fun spawnNewAsteroid(asteroid: SpaceObject) {
    val newOne: SpaceObject? = SpaceObjects.firstOrNull { it.type == SpaceObjectType.ASTEROID && ! it.active }
    if (newOne != null) {
        val newVelocity = asteroid.velocity.rotate(Random.nextDouble(90.0,270.0))
        activateAsteroid(newOne, asteroid.scale, asteroid.position, newVelocity)
    }
}

private fun splitOrKillAsteroid(asteroid: SpaceObject) {
    if (asteroid.scale > 1) {
        activateAsteroid(asteroid, asteroid.scale / 2, asteroid.position, randomVelocity())
        spawnNewAsteroid(asteroid)
    } else deactivate(asteroid)
}

Test. Yes. All the little asteroids are separating nicely now. I am called upon to take a break.

We are, I think, good. And way behind on commits. Commit: asteroids are drawn at a random rotation. Refactor asteroid activation to centralize operations.

Time to sum up. Important learning again for the umteenth time.

Summary

I found when setting the asteroids to a random rotation that there were three different places where that needed to be done, signaling serious duplication. In attempting to refactor that down to one, I was doing fine until suddenly … it seemed that passing the angle of motion rather than the vector was a good idea. It wasn’t and it didn’t look right on the screen.

So I had to change it back. Note “didn’t look right on the screen”. I had no test for the feature, so when split asteroids started sometimes following too similar paths, I had to detect it, diagnose it, and fix it, all by eye.

Some things are like that, of course, but I’d prefer something that had a solid executable check. I do not know how to do that in this case, but I’ll think about it. Since what I’m doing is random … it’s hard to imagine a direct check. We could draw a bunch of random numbers and check them all, of course, but that’s really not pretty.

Anyway, I think we’re good now, but the session wasn’t as simple and smooth as I had in mind.

See you next time!