GitHub Decentralized Repo
GitHub Centralized Repo
GitHub Flat Repo

I’ve forgotten that the ECS technique provides for a bit of polymorphism. We need some and will soon need more. Let’s see if we can make that happen.

Frequent readers may recall that in the early morning hours before I actually roll out of bed, I often think about my current programming project, to keep undesirable worrying thoughts at bay. This morning I was thinking about the if statement in the draw function, and realized that the drawing operation is a perfectly reasonable component to have.

There would not be any particular savings to doing that, unless we believe the performance myth that surrounds ECS. So far, I’ve been unable to demonstrate any memory-caching performance differences in this little program. But we could do something more. I’m not sure whether this violates our “no methods” rule or not. No, I am sure that it will, but let’s try it anyway.

Our components, as is ECS tradition, all implement update. Of course, we only have one such component at present:

interface Component { val entity: SpaceObject }

data class Timer(override val entity: SpaceObject, val startTime: Double): Component {
    var time = startTime
}

I’m thinking of making a new component, to be called Painter, because “drawer” is taken. We’ll provide two different versions of Painter, one for everyone except the ship, and one for the ship that also draws the flare. There are two issues here. The first is that we execute all our components in one place and after that we draw:

fun gameCycle(
    spaceObjects: Array<SpaceObject>,
    width: Int,
    height: Int,
    drawer: Drawer,
    deltaTime: Double
) {
    for (spaceObject in spaceObjects) {
        for (component in spaceObject.components) update(component, deltaTime)
        if (spaceObject.type == SpaceObjectType.SHIP) applyControls(spaceObject, deltaTime)
        move(spaceObject, width, height, deltaTime)
    }
    for (spaceObject in spaceObjects) {
        if (spaceObject.active) draw(spaceObject, drawer)
    }
    checkCollisions()
}


fun update(component: Component, deltaTime: Double) {
    when (component) {
        is Timer -> {
            updateTimer(component, deltaTime)
        }
    }
}

Now a more reasonable implementation here would be to have an update method on Timer, but we aren’t allowing that to happen, because we want all top-level functions in this implementation. We’re pretending that we don’t have an OO language to build asteroids, so as to get a design comparable to the original. So we dispatch in our update based on the component class. This is, of course, a poor man’s method dispatch.

Since this code works, and based on a check of the Kotlin documentation, I conclude that Kotlin’s is is a run-time check on the object’s type, since all our components list knows is that the object is a Component, not what specific type it is.

So we can have different types of Painters, and check them directly. But I hate that.

If we can’t attach behavior to a Component, we’ll wind up with a larger and larger dispatch there in update.

I’m faced with a dilemma (trilemma?) and I didn’t even get to my second issue yet. As I see it now, as through a glass, darkly, my options are:

  1. Leave the if in draw, which will mean that there’s little or no advantage to making it a Component.

  2. Change the rules and put update as a method on Component, rationalizing that we have some kind of function pointer there. Perhaps allow no other such OO dispatch.

  3. Bend the rules and give Component a behavior lambda, which is a function pointer. Thus the rules have not been broken.

Between #2 and #3, I think #2 is preferable because it’ll be more clear what happens. Putting a lambda in there just obfuscates how things work. An additional argument in favor of #2 is that ECS generally requires the Component to implement update.

It’s tempting just to move on, accepting #1 as closest to the rules of this series, allowing things like the if statements to add up, making our comparison more vivid. In the particular case of the draw function, everyone has one, so there’s no obvious advantage to making a Painter component anyway.

Raising our head up a bit, we can say the same for the moving operation. Everything moves, so we accrue no savings from making a Mover component, since everyone would get one.

However

We do need to implement showing the score and available ships. We could, in principle, create a new kind of SpaceObject that draws the score. It would have an entirely different kind of drawing, and wouldn’t move at all. So we could imagine using it to justify making Mover and Painter as components, giving the score no mover at all, and giving it a different kind of Painter, which would then provide a platform for the ship’s specialized painter, which would get very slightly simplify this code:

fun draw(
    spaceObject: SpaceObject,
    drawer: Drawer,
) {
    drawer.isolated {
        val scale = 4.0 *spaceObject.scale
        drawer.translate(spaceObject.x, spaceObject.y)
        drawer.scale(scale, scale)
        drawer.rotate(spaceObject.angle)
        drawer.stroke = ColorRGBa.WHITE
        drawer.strokeWeight = 1.0/scale
        possiblyDrawFlare(spaceObject, drawer)
        drawer.lineStrip(spaceObject.type.points)
    }
}

private fun possiblyDrawFlare(spaceObject: SpaceObject, drawer: Drawer) {
    if (spaceObject.type == SpaceObjectType.SHIP) {
        if (Controls.accelerate && Random.nextInt(1, 3) == 1) {
            drawer.lineStrip(shipFlare)
        }
    }
}

ECS might “simplify” the above code by creating two versions of the regular drawing code, one with the flare and one without, plus one version with the score code. With some extraction, we’d get something like this:

fun regularDraw(...) {
    setupAndDrawPoints(...)
}

fun shipDraw(...) {
    setupAndDrawPoints(...)
    drawFlare(...)
}

fun scoreDraw(...){
	setup ...
	draw text
	draw ships
}

I’m not finding the motivation to do this. I think that our minimal ECS has paid off for the Timer, which is only used once so far, and that’s fine. But the force toward using ECS for drawing and moving just isn’t there.

Maybe the force will grow. Let’s move on to more features and see what the code tells us.

Score

Let’s do the score display. We’ll start with just the score part and do the available ships part later.

First, we need to keep score. In the present style, that’s going to be a global Int.

var Score: Int = 0
lateinit var SpaceObjects: Array<SpaceObject>
lateinit var Ship: SpaceObject

I renamed spaceObjects to SpaceObjects because it’s global and I was taught, last century, to capitalize my globals. We can commit this.

Whoa! I get a serious error:

Unable to load class 'org.jetbrains.kotlin.gradle.tasks.KotlinCompile'.
This is an unexpected error. Please file a bug containing the idea.log file.

OK, I’ve gotten around that problem. I hate it when things like that happen. Anyway, let’s do some scoring tests now that we have a Score to check.

    @Test
    fun `vanilla asteroid scores 20`() {
        val asteroid = newAsteroid()
        asteroid.position = Vector2(100.0, 100.0)
        asteroid.active = true
        asteroid.scale = 4.0
        val missile :SpaceObject = newMissile()
        missile.position = Vector2(100.0,100.0)
        missile.active = true
        checkOneAsteroid(asteroid,missile)
        assertThat(asteroid.active).isEqualTo(false)
        assertThat(Score).isEqualTo(20)
    }

With any luck this fails on the 20. Curiously, it fails on the false:

expected: false
 but was: true

Oh. Of course it stays active, it’s just smaller. Change the test:

    @Test
    fun `vanilla asteroid scores 20`() {
        val asteroid = newAsteroid()
        asteroid.position = Vector2(100.0, 100.0)
        asteroid.active = true
        asteroid.scale = 4.0
        val missile :SpaceObject = newMissile()
        missile.position = Vector2(100.0,100.0)
        missile.active = true
        checkOneAsteroid(asteroid,missile)
        assertThat(asteroid.scale).isEqualTo(2.0)
        assertThat(Score).isEqualTo(20)
    }

Now I should get the 20 fail. Yes. Now we can implement the scoring. That’ll go in here somewhere:

fun checkOneAsteroid(
    asteroid: SpaceObject,
    missile: SpaceObject
) {
    if (colliding(asteroid, missile)) {
        if (asteroid.scale > 1) {
            asteroid.scale /= 2
            asteroid.velocity = randomVelocity()
            spawnNewAsteroid(asteroid)
        } else deactivate(asteroid)
        deactivate(missile)
    }
}

Easy.

fun checkOneAsteroid(
    asteroid: SpaceObject,
    missile: SpaceObject
) {
    if (colliding(asteroid, missile)) {
        Score += 20
        if (asteroid.scale > 1) {
            asteroid.scale /= 2
            asteroid.velocity = randomVelocity()
            spawnNewAsteroid(asteroid)
        } else deactivate(asteroid)
        deactivate(missile)
    }
}

Yes, I could do it all, but the discipline is to write just enough code to make the test pass. I could have said Score = 20 but that would be too silly even for me. Test expecting green. Fail, because we never clear the Score. For now we’ll clear in the test.

    @Test
    fun `vanilla asteroid scores 20`() {
        val asteroid = newAsteroid()
        asteroid.position = Vector2(100.0, 100.0)
        asteroid.active = true
        asteroid.scale = 4.0
        val missile :SpaceObject = newMissile()
        missile.position = Vector2(100.0,100.0)
        missile.active = true
        Score = 0
        checkOneAsteroid(asteroid,missile)
        assertThat(asteroid.scale).isEqualTo(2.0)
        assertThat(Score).isEqualTo(20)
    }

This better pass. And it does. Let’s change the test this way:

    @Test
    fun `scale 4 asteroid increases score by 20`() {
        val asteroid = newAsteroid()
        asteroid.position = Vector2(100.0, 100.0)
        asteroid.active = true
        asteroid.scale = 4.0
        val missile :SpaceObject = newMissile()
        missile.position = Vector2(100.0,100.0)
        missile.active = true
        val oldScore = Score
        checkOneAsteroid(asteroid,missile)
        assertThat(asteroid.scale).isEqualTo(2.0)
        assertThat(Score).isEqualTo(oldScore+20)
    }

Now we’re actually checking for the increment. I think that’s better. I’ll commit: scale 4 asteroids score correctly.

Now I’m going to to hog wild and implement the other two tests.

    @Test
    fun `scale 2 asteroid increases score by 50`() {
        val asteroid = newAsteroid()
        asteroid.position = Vector2(100.0, 100.0)
        asteroid.active = true
        asteroid.scale = 2.0
        val missile :SpaceObject = newMissile()
        missile.position = Vector2(100.0,100.0)
        missile.active = true
        val oldScore = Score
        checkOneAsteroid(asteroid,missile)
        assertThat(asteroid.scale).isEqualTo(1.0)
        assertThat(Score).isEqualTo(oldScore+50)
    }

    @Test
    fun `scale 1 asteroid increases score by 100`() {
        val asteroid = newAsteroid()
        asteroid.position = Vector2(100.0, 100.0)
        asteroid.active = true
        asteroid.scale = 1.0
        val missile :SpaceObject = newMissile()
        missile.position = Vector2(100.0,100.0)
        missile.active = true
        val oldScore = Score
        checkOneAsteroid(asteroid,missile)
        assertThat(asteroid.active).isEqualTo(false)
        assertThat(Score).isEqualTo(oldScore+100)
    }

I don’t like that clever technique of using the existing score because the messages are odd. We can do better:

        assertThat(Score- oldScore).isEqualTo(100)

And so on in the others. Now the errors make sense:

expected: 50
 but was: 20

Let’s finally do it:

fun checkOneAsteroid(
    asteroid: SpaceObject,
    missile: SpaceObject
) {
    if (colliding(asteroid, missile)) {
        Score += getScore(asteroid)
        if (asteroid.scale > 1) {
            asteroid.scale /= 2
            asteroid.velocity = randomVelocity()
            spawnNewAsteroid(asteroid)
        } else deactivate(asteroid)
        deactivate(missile)
    }
}

private fun getScore(asteroid: SpaceObject): Int {
    return when (asteroid.scale) {
        4.0 -> 20
        2.0 -> 50
        1.0 -> 100
        else -> 0
    }
}

This should pass the tests and now that I’ve done it this way I can improve them. First pass. Commit: asteroids score properly.

I was going to change the tests to just call getScore but that seems too invasive and not as safe as checking the resulting score. We’ll let this stand, Let’s sum up.

Summary

Half-asleep design thinking made me think that ECS might be useful for drawing, but cold light of day thinking says it’s not worth it at this point.

We moved on to implement Score. The game is now keeping score, and it’s surely even correct, except that we need to reset the score in Game. Let me do that before I forget.

fun createGame(missileCount: Int, asteroidCount: Int) {
    Score = 0
    val objects = mutableListOf<SpaceObject>()
    for (i in 1..missileCount) objects.add(newMissile())
    Ship = newShip()
    objects.add(Ship)
    for (i in 1..asteroidCount) objects.add(newAsteroid())
    SpaceObjects = objects.toTypedArray()
}

No test for that. Bad Ron, no biscuit. Commit: clear score on createGame.

Anyway, scoring is quite simple, just a when. Of course when there’s another way to kill an asteroid, such as by ramming it, we will have to deal with whether you get points for that or not. Still, no matter how we implement scoring, we have to address that issue.

For now, a bit of thinking and a bit of coding. We’ll display score next time, I reckon.

See you then!