GitHub Repo

Let’s give the Saucer some more capability. And a question/request to readers.

I am notably terrible actually playing the asteroids game. I blame the programmer. But our Saucer doesn’t yet do what the original game’s did, so we must make the game even more difficult for its only known player, me. Poor poor pitiful me.

I think that for best results, we need the game to bring the saucer back to life if it dies. Presently if by some strange chance I shoot it down, or, far more likely, it blunders into an asteroid, the Saucer is gone from mortal ken, never to return unless we restart the game. That won’t do. The Saucer is supposed to run every seven seconds from its last demise.

Let’s build a SaucerMaker. It will know the Saucer, as the ShipMaker knows the Ship, and when the Saucer has been out of the mix for seven seconds, the SaucerMaker will dump it back into the mix.

In the case of the ShipMaker, there is a separate ShipChecker that notices the ship gone, and rezzes the ShipMaker. Let’s try this time to do it all in one object. We could copy the ShipChecker/Maker, perhaps even use the same code, but that’s no fun and we might learn more from doing the SaucerMaker anew.

Aside
I have to confess that I was thinking, before we started, that I’m approaching this game differently from other projects that I’ve done here. I’m working more loosely. Well, for a game, I’ve done pretty well with the testing, but I’m not as relentlessly removing duplication, boiling everything down to the tightest code I can create. (That’s not to say that I’m ever anywhere near perfect. Or even good.) I’m definitely playing with this code, giving myself more freedom to do odd things. I do still notice when that doesn’t work out for me, and I write it up here so you can see the error of my ways. But I’m definitely working at a lower intensity, and I never work very intensely anyway.

I think this looseness is probably able to be spotted in the code. We’d see duplicated code that could easily be removed. We’d see similar things done in different ways. Would there be other signs, in the code? You tell me.

SaucerMaker

What should SaucerMaker do? Something like this:

  1. Get created with a saucer instance.
  2. In every interaction cycle, observe whether there is a saucer in the mix.
  3. If there is not a saucer in the mix
  4. And seven seconds have elapsed since we first noticed
  5. Put the saucer into the mix.

I want a test for this, and it’ll be another of my story tests. I don’t like those, but I don’t see a good way to do otherwise. Let’s get started.

I just write a few lines of test, enough to drive out the maker:

    @Test
    fun `notices that saucer is not present`() {
        val saucer = Saucer()
        val maker = SaucerMaker(saucer)
    }

Let IDEA offer me a new class. I plug in its interfaces. IDEAA implements empty members:


class SaucerMaker(saucer: Saucer): InteractingSpaceObject, ISpaceObject  {
    override fun update(deltaTime: Double, trans: Transaction) {
        TODO("Not yet implemented")
    }

    override fun finalize(): List<ISpaceObject> {
        TODO("Not yet implemented")
    }

    override val subscriptions: Subscriptions
        get() = TODO("Not yet implemented")

    override fun callOther(other: InteractingSpaceObject, trans: Transaction) {
        TODO("Not yet implemented")
    }

}

I rework the test a bit, to check some elementary state, since the maker will init itself to not seeing the saucer and then determine that it has, when it does:

    @Test
    fun `notices whether saucer present`() {
        val saucer = Saucer()
        val maker = SaucerMaker(saucer)
        val trans = Transaction()
        maker.subscriptions.beforeInteractions()
        assertThat(maker.sawSaucer).isEqualTo(false)
        maker.subscriptions.interactWithSaucer(saucer, trans)
        assertThat(maker.sawSaucer).isEqualTo(true)
    }

And this is enough to make it green.

class SaucerMaker(saucer: Saucer): InteractingSpaceObject, ISpaceObject  {
    var sawSaucer: Boolean = false

    override val subscriptions: Subscriptions = Subscriptions(
        beforeInteractions = { sawSaucer = false},
        interactWithSaucer = { _, _ -> sawSaucer = true },
    )

    override fun callOther(other: InteractingSpaceObject, trans: Transaction) {
        // no direct interactions
    }
}

Now what? Well, if we get to afterInteractions, we want to rez the saucer, but only if seven seconds have elapsed since we last saw it.

We get deltaTime in update, so we can increment the time since last seen there … and if we do see the saucer, set it back to zero. So … let’s do a new test.

But we’re green. We can commit now. In the interest of small steps, let’s do. Commit: initial SaucerMaker notices saucer presence.

Now that new test:

    @Test
    fun `makes saucer after seven seconds`() {
        val saucer = Saucer()
        val maker = SaucerMaker(saucer)
        val trans = Transaction()
        maker.update(0.01, trans)
        maker.subscriptions.beforeInteractions()
        // no saucer for you
        maker.subscriptions.afterInteractions(trans)
        assertThat(trans.adds).isEmpty()
        maker.update(7.0, trans)
        maker.subscriptions.beforeInteractions()
        // no saucer for you
        maker.subscriptions.afterInteractions(trans)
        assertThat(trans.adds).contains(saucer)
    }

I think that’s the story. Running the test will drive out update:

class SaucerMaker(saucer: Saucer): InteractingSpaceObject, ISpaceObject  {
    var sawSaucer: Boolean = false
    var timeSinceLastSaucer = 0.0

    override fun update(deltaTime: Double, trans: Transaction) {
        timeSinceLastSaucer += deltaTime
    }

Test will fail not finding saucer at the end.

Expecting LinkedHashSet:
  []
to contain:
  [com.ronjeffries.ship.Saucer@e98770d]
but could not find the following element(s):
  [com.ronjeffries.ship.Saucer@e98770d]

Let’s create the saucer in afterInteractions. I’ll forget to do anything about the time, to get another failure.

    override val subscriptions: Subscriptions = Subscriptions(
        beforeInteractions = { sawSaucer = false},
        interactWithSaucer = { _, _ -> sawSaucer = true },
        afterInteractions = { trans ->
            trans.add(saucer)
        }
    )

I think this should fail on the isEmpty check.

Expecting empty but was: [com.ronjeffries.ship.Saucer@3016fd5e]

Perfect. Now we need to check the time … but our test is not robust enough. I make a note to write another one, but I’m going to code this correctly, if I can.

    override val subscriptions: Subscriptions = Subscriptions(
        beforeInteractions = { sawSaucer = false},
        interactWithSaucer = { _, _ -> 
            timeSinceLastSaucer = 0.0
            sawSaucer = true },
        afterInteractions = { trans ->
            if (timeSinceLastSaucer > 7.0) trans.add(saucer)
        }
    )

I expect green. Curiously enough, I get green. Now, before I write the needed test, I want to try this in the game. Change game’s setup …

When I look at the setup, I find this:

    fun createContents(controls: Controls) {
        add(ShipChecker(newShip(controls)))
        add(ScoreKeeper())
        add(WaveChecker())
        val saucer = Saucer()
        saucer.wakeUp()
        add(saucer)
    }

I remember now … saucer needs a wake up call, to tell it to reverse direction and such. I make a note of that for testing too, but I want to get the mix set up:

    fun createContents(controls: Controls) {
        add(ShipChecker(newShip(controls)))
        add(ScoreKeeper())
        add(WaveChecker())
        add(SaucerMaker(Saucer()))
    }

Writing it this way tells me that we could have SaucerMaker create the Saucer ab initio1. Table that too. Gotta try this in the game.

Playing the game I learn a couple of things: The Saucer does not time out, it will run until destroyed somehow. And, when it is destroyed, it starts wherever it died, because it’s not being sent wakeUp.

And the third thing is that we don’t get any points for killing the saucer, but that’s not a bug, that’s a feature not yet scheduled.

There’s a fourth thing (will things never end?) which is that there is a delay before the sauce starts moving. That’ll be because it didn’t get a wake up, which sets its initial velocity.

I think the saucer should do its own initializing. And it can use finalize to reset itself for next time. Let’s look at our rudimentary SaucerTest.

    @Test
    fun `starts left-right`() {
        val saucer = Saucer()
        saucer.wakeUp()
        assertThat(saucer.velocity.x).isGreaterThan(0.0)
        assertThat(saucer.velocity.y).isEqualTo(0.0)
    }

    @Test
    fun `direction changes`() {
        var dir: Velocity
        val saucer = Saucer()
        saucer.wakeUp()
        dir = saucer.newDirection(1)
        assertThat(dir.x).isEqualTo(0.7071, within(0.0001))
        assertThat(dir.y).isEqualTo(0.7071, within(0.0001))
        dir = saucer.newDirection(2)
        assertThat(dir.x).isEqualTo(0.7071, within(0.0001))
        assertThat(dir.y).isEqualTo(-0.7071, within(0.0001))
        dir = saucer.newDirection(0)
        assertThat(dir.x).isEqualTo(1.0, within(0.0001))
        assertThat(dir.y).isEqualTo(0.0, within(0.0001))
    }

Right, these are assuming wakeUp. Let’s remove those calls and see what breaks. First, I add another test:

    @Test
    fun `right left next time`() {
        val saucer = Saucer()
        saucer.finalize()
        assertThat(saucer.velocity.x).isLessThan(0.0)
        assertThat(saucer.velocity.y).isEqualTo(0.0)
    }

Test. Two fail, as expected:

left-right
Expecting actual:
  0.0
to be greater than:
  0.0
right left next time
Expecting actual:
  0.0
to be less than:
  0.0 

Fix saucer, which looks like this:

class Saucer : ISpaceObject, InteractingSpaceObject, Collider {
    override var position = Point(0.0, Random.nextDouble(U.UNIVERSE_SIZE))
    override val killRadius = 100.0
    private var direction = -1.0 // right to left, will invert on `wakeUp`
    var velocity = Velocity.ZERO
    private val directions = listOf(Velocity(1.0,0.0), Velocity(0.7071,0.7071), Velocity(0.7071, -0.7071))
    private val speed = 1500.0
    private var elapsedTime = 0.0


    fun wakeUp() {
        direction = -direction
        assignVelocity(Velocity(direction, 0.0))
    }

    private fun assignVelocity(unitV: Velocity) {
        velocity = unitV*speed
    }

    override fun finalize(): List<ISpaceObject> {
        return emptyList()
    }

I think we need to call wakeUp in finalize. But we also should call it when the object is initialized. And let’s clean things up a bit while we’re at it. First this:

    init {
        wakeUp()
    }

    override fun finalize(): List<ISpaceObject> {
        wakeUp()
        return emptyList()
    }

Test. I think the tests might go green. They do. Now to clean up a bit.

private val Directions = listOf(
    Velocity(1.0, 0.0),
    Velocity(0.7071, 0.7071),
    Velocity(0.7071, -0.7071)
)

class Saucer : ISpaceObject, InteractingSpaceObject, Collider {
    override lateinit var position: Point
    override val killRadius = 100.0

    private var direction: Double
    lateinit var velocity: Velocity
    private val speed = 1500.0
    private var elapsedTime = 0.0

    init {
        direction = -1.0
        wakeUp()
    }

    private fun wakeUp() {
        direction = -direction
        position = Point(0.0, Random.nextDouble(U.UNIVERSE_SIZE))
        velocity = Velocity(direction, 0.0) * speed
    }

I just moved the directions outside for neatness, and inlined the oddly-named assignVelocity.

Tests are green but when I play the game, the Saucer does start from the right on the second time around, but when it changes direction it reverses back to going left to right.

I see what the bug is. Let’s write a test first.

    @Test
    fun `direction changes maintain left-right`() {
        val saucer = Saucer() // left to right
        saucer.finalize() // right to left
        saucer.zigZag()
        assertThat(saucer.velocity.x).isLessThan(0.0)
    }

There is no method zigZag, but we can extract it from here:

    override fun update(deltaTime: Double, trans: Transaction) {
        elapsedTime += deltaTime
        if (elapsedTime > 1.5) {
            elapsedTime = 0.0
            velocity = newDirection(Random.nextInt(3)) * speed
        }
        position = (position + velocity * deltaTime).cap()
    }

That velocity-setting line is the code that incorrectly implements zigZag. Extract it and test expecting a fail.

    override fun update(deltaTime: Double, trans: Transaction) {
        elapsedTime += deltaTime
        if (elapsedTime > 1.5) {
            elapsedTime = 0.0
            zigZag()
        }
        position = (position + velocity * deltaTime).cap()
    }

    fun zigZag() {
        velocity = newDirection(Random.nextInt(3)) * speed
    }

Test. Fail

Expecting actual:
  1060.6499999999999
to be less than:
  0.0 

Where did that number come from tho? Probably it’s 0.7071 times 1500.

Fix the bug:

    fun zigZag() {
        velocity = newDirection(Random.nextInt(3)) * speed * direction
    }

I forgot to apply the direction constant, plus or minus one, to the final direction. I think I don’t like the fact that the word “direction” has two different meanings. But first I want to try the game, just can’t resist. Works as intended.

Let’s see. We can surely commit: Saucer appears every seven seconds after it goes missing. Fixed bug in reversal.

Let’s review.

Retrospective

We have some outstanding notes:

  1. SaucerMaker create Saucer.
  2. Saucer timeout after 7 seconds?
  3. 14 second test
  4. wake up saucer.

I think #4 is resolved, we sorted out the init and wake up calls. The 14 second test … what did I have in mind for that? Honestly, I don’t remember. We tested that the saucer isn’t created until seven seconds of no saucer have elapsed. Oh. I think the concern was that after seven seconds for the first missing saucer, we’d create further ones instantly. We can test that.

    @Test
    fun `a further seven seconds required for next saucer`() {
        val saucer = Saucer()
        val maker = SaucerMaker(saucer)
        val trans = Transaction()
        maker.update(7.1, trans)
        maker.subscriptions.beforeInteractions()
        // no saucer for you
        maker.subscriptions.afterInteractions(trans)
        assertThat(trans.adds).contains(saucer)
        val trans2 = Transaction()
        maker.update(0.1, trans2)
        maker.subscriptions.beforeInteractions()
        maker.subscriptions.interactWithSaucer(saucer,trans2)
        maker.subscriptions.afterInteractions(trans2)
        assertThat(trans2.adds).describedAs("added too soon").isEmpty() // no saucer created before its time
    }

Green. I’m going to defer on the seven second timeout for saucers. I want to review the original game. It might make more sense to just let it fly until it crashes.

Although … here’s a difficult one … if the saucer manages to shoot down the ship … i think we want it to disappear also. I make a new note for that. It’s going to be tricky … how will the saucer know?

Let’s do allow the SaucerMaker to create the saucer, but let’s make it optional, since in our tests we want to use it.

class SaucerMaker(saucer: Saucer = Saucer()): InteractingSpaceObject, ISpaceObject  {

This wasn’t quite as gratuitous as it looks, because I changed Game to look like this:

    fun createContents(controls: Controls) {
        add(ShipChecker(newShip(controls)))
        add(ScoreKeeper())
        add(WaveChecker())
        add(SaucerMaker())
    }

I think we’ll leave it like this for now. Test. Commit: SaucerMaker creates the saucer when Maker is created. Test added to confirm seven second timer starts over.

Let’s sum up and plan a bit.

Summary

The SaucerMaker went in easily. It seems that most of our little objects go in easily: the standard flow of the system provides convenient events where we can hang behavior. This isn’t the only way we could have ease of object creation, but it certainly seems to be one way.

What lies ahead?

  • We’ll have to do something interesting to have the saucer die when the ship does, if we want that behavior.
  • Saucer needs to fire missiles randomly.
  • Saucer needs to fire a targeted missile even more randomly.
  • Saucer needs scoring.
  • Saucer comes in two sizes, large easy size and small difficult size.

And I noticed when shooting at the saucer that clear hits seemed to be scored as misses. So I’v drawn the ship and saucer with their kill radius shown in red, and bumped the saucer’s radius up to 150 (same as the ship):

ship and saucer with kill radii

All in all, these little objects are serving pretty well. Things are going so smoothly that I’m almost bored.

Question/Request
Which reminds me: If you know a truly easy simple not involving loading five million lines of code way to play WAV and similar sound files in Kotlin/OPENRNDR, please hit me up. The game is too quiet.

See you next time!



  1. “From the beginning.” Latin. Showoff.