GitHub Repo

‘When things get too complicated, add simplicity.’’ Let’s try that.

My plan, such as it is, is to move forward from where we are. I could imagine reverting back a few days, but as a base, a few days ago isn’t really better than what we have today, so we’ll go forward.

My detailed plan — well, as detailed as I ever do — is to scale the game to match the original, 1024x1024, and to scale all the objects to match Asteroids as well. And, to begin, I’m going to make the screen size 1024x1024 as well, though I think we’ll move beyond that before we’re done.

As we work, we’ll try to record all the various scale factors, kill distances, and so on, as universal constants, so that we can at least see their relationships and edit in one place instead of all over creation.

I think we won’t be able to commit a working game until we’re done, though there may be some stages where things are back to looking right, in which case we’ll definitely commit. We want to take small steps: it’s just that there’s a lot of coordination needed to accomplish that, which makes for larger steps.

Let’s go.

Here are the universal constants as they stand:

object U {
    const val DRAW_SCALE = 15.0
    const val UNIVERSE_SIZE = 10000.0
    const val ASTEROID_SPEED = 1000.0
    const val DROP_SCALE = 3.0
    const val MAKER_DELAY = 3.0
    const val SAFE_SHIP_DISTANCE = UNIVERSE_SIZE/10.0
    const val SAUCER_SPEED = 1500.0
    const val SAUCER_LIFETIME = 10.0
    const val SPEED_OF_LIGHT = 5000.0
    const val SPLAT_LIFETIME = 2.0
    const val SHIP_ROTATION_SPEED = 200.0 // degrees per second
    val SHIP_ACCELERATION = Velocity(1200.0, 0.0)
    val CENTER_OF_UNIVERSE = Point(UNIVERSE_SIZE / 2, UNIVERSE_SIZE / 2)

I’ll adjust some of these right out of the box, as we do. For one, DRAW_SCALE will be set to 1.0. Oh, and while I think of it, I’ll define window size here and use it in the game main.

    const val DRAW_SCALE = 1.0
    const val UNIVERSE_SIZE = 1024.0
    const val WINDOW_SIZE = 1024
    const val ASTEROID_SPEED = 100.0
    const val DROP_SCALE = 3.0
    const val MAKER_DELAY = 3.0
    const val SAFE_SHIP_DISTANCE = UNIVERSE_SIZE/10.0
    const val SAUCER_SPEED = 150.0
    const val SAUCER_LIFETIME = 10.0
    const val SPEED_OF_LIGHT = 500.0
    const val SPLAT_LIFETIME = 2.0
    const val SHIP_ROTATION_SPEED = 200.0 // degrees per second
    val SHIP_ACCELERATION = Velocity(120.0, 0.0)

Basically I just divided speed things by 10, figuring that will be close. Now in the main:

fun main() = application {
    configure {
        width = U.WINDOW_SIZE
        height = width
    }
    ...

Run to see how it looks. Pretty much nothing shows up on the screen but the text. Let’s go through the objects and back out the 15/30 whatever random scale values they have. Right now, the only scale they need is their own, relative to their points definition.

I’m trying to get the ship to display. It has a strokeWeight:

    private val strokeWeight = 8.0/30.0

Let’s change that to 8.0 since our scale is now more like 1 than 30. Draw looks like this:

    fun draw(drawer: Drawer) {
        drawer.translate(position)
//        drawKillRadius(drawer)
        drawer.scale(U.DRAW_SCALE, U.DRAW_SCALE)
        drawer.strokeWeight = strokeWeight
        drawer.scale(dropScale, dropScale)
        drawer.rotate(heading )
        drawer.stroke = ColorRGBa.WHITE
        drawer.lineStrip(points)
        if ( accelerating ) {
            displayAcceleration = (displayAcceleration + 1)%3
            if ( displayAcceleration == 0 ) {
                drawer.strokeWeight = 2.0*strokeWeight
                drawer.lineStrip(flare)
            }
        }
    }

Position should be fine, mid-screen. dropScale goes from 3 down to 1, so that seems OK. And U.DRAW_SCALE is 1.0. But I’ll remove that anyway.

When I look very carefully, I see a tiny asteroids game up in the upper left corner of the screen. Someone has a tiny scale going. Ha! It’s in the main:

        extend {
            val worldScale = width/10000.0
            drawer.fontMap = font
            drawer.scale(worldScale, worldScale)
            game.cycle(seconds, drawer)
        }

Let’s just wipe that right out. If it had properly used universal constants it would have been OK. Run.

The game sort of appears. The ship’s lines are way too think, and the score and hints are HUGE. Fix those:

I find in main that the font size is set to 640, change that:

    program {
        val font = loadFont("data/fonts/default.otf", 64.0)

In ScoreKeeper we need to adjust some values> I do them ad hoc to get things close to right. Make note to scale them sensibly. We want to get the game right.

The ship is clearly too small and we (think we) know that the original game scales it by 2. So I’ll change that:

    fun draw(drawer: Drawer) {
        drawer.translate(position)
//        drawKillRadius(drawer)
        drawer.strokeWeight = strokeWeight
        drawer.scale(U.SCALE_SHIP, U.SCALE_SHIP)
        drawer.scale(dropScale, dropScale)
        drawer.rotate(heading )
        drawer.stroke = ColorRGBa.WHITE
        drawer.lineStrip(points)
        if ( accelerating ) {
            displayAcceleration = (displayAcceleration + 1)%3
            if ( displayAcceleration == 0 ) {
                drawer.strokeWeight = 2.0*strokeWeight
                drawer.lineStrip(flare)
            }
        }
    }

I’m trying to keep to my plan to make all the constants explicit up in the U area.

object U {
    const val SCALE_SHIP = 2.0

Test. Ship looks good but too bright. We’ll worry about that in a bit, let’s get scale right. I think this is about right:

ship right size

Kill radii really need work as well. Stick to scale. Asteroids are too small. They start at 8 wide. We want the smallest to be just a bit larger than the ship. The ship size is 14. At scale 2, 28. So smallest asteroid should be 32. So:

object U {
    const val SCALE_SHIP = 2.0
    const val SCALE_ASTEROID = 4.0

And in AsteroidView:

    fun draw(asteroid: Asteroid, drawer: Drawer) {
        drawer.stroke = ColorRGBa.WHITE
        drawer.strokeWeight = 16.0
        drawer.fill = null
        val sc = asteroid.scale() // 2,4,8
        drawer.scale(sc,sc) // 30, 60, 120 net
        drawer.rotate(asteroid.heading)
        drawer.stroke = ColorRGBa.WHITE
        drawer.strokeWeight = 8.0/30.0/sc
        drawer.scale(1.0, -1.0)
        drawer.lineStrip(rock)
    }

I know that asteroid.scale is not 2,4,8, as the comment says. Fix it from:

    fun scale() =2.0.pow(splitCount)*2.0

To:

    fun scale() =2.0.pow(splitCount)

And:

    fun draw(asteroid: Asteroid, drawer: Drawer) {
        drawer.stroke = ColorRGBa.WHITE
        drawer.fill = null
        val sc = asteroid.scale()*U.SCALE_ASTEROID
        drawer.scale(sc,sc) 
        drawer.rotate(asteroid.heading)
        drawer.stroke = ColorRGBa.WHITE
        drawer.strokeWeight = U.STROKE_ASTEROID
        drawer.scale(1.0, -1.0) // upside down
        drawer.lineStrip(rock)
    }

With the new plan including STROKE values in U:

    const val STROKE_ASTEROID = 2.0

Try this. Asteroids have really fat lines. They need to scale down by the value of sc as they did before:

    fun draw(asteroid: Asteroid, drawer: Drawer) {
        drawer.stroke = ColorRGBa.WHITE
        drawer.fill = null
        val sc = asteroid.scale()*U.SCALE_ASTEROID
        drawer.scale(sc,sc)
        drawer.rotate(asteroid.heading)
        drawer.stroke = ColorRGBa.WHITE
        drawer.strokeWeight = U.STROKE_ASTEROID/sc
        drawer.scale(1.0, -1.0) // upside down
        drawer.lineStrip(rock)
    }

Still too thick, I’ll make it 1.0 for now.

What else? The missiles are the size of asteroids.

    fun draw(drawer: Drawer) {
        drawer.fill = ColorRGBa.MEDIUM_SLATE_BLUE
        drawer.translate(position)
        drawer.stroke = color
        drawer.fill = color
        drawer.circle(Point.ZERO, killRadius * 3.0)
    }

Here we have a need for a universal constant, and why is the radius 3 times killRadius anyway? The killRadius is presently 10. Make it 1 and let it be?

object U {
    const val KILL_MISSILE = 1.0

class Missile {
    override val killRadius: Double = U.KILL_MISSILE
}

Test. Looks decent but Missiles fire about a kilometer ahead of the ship now.

    init {
        val missileOwnVelocity = Velocity(U.SPEED_OF_LIGHT / 3.0, 0.0).rotate(shipHeading)
        val standardOffset = Point(2 * (shipKillRadius + killRadius), 0.0)
        val rotatedOffset = standardOffset.rotate(shipHeading)
        position = shipPosition + rotatedOffset
        velocity = shipVelocity + missileOwnVelocity
    }

Ah, that’s the ship radius that’s hurting us. Might as well fix it while it’s on our mind:

class Ship(
    override var position: Point,
    val controls: Controls = Controls(),
    override val killRadius: Double = U.KILL_SHIP

The ship is 12 pixels (times 2) without flare. Let’s set the radius to 12 and see how we like it. They seem to emit about where I’d expect now. They draw rather large. We’ll set that tuning aside. Let’s go deal with Asteroid kill radii, since we’ve started bringing those values up to U. Currently, that’s somewhat bizarre:

    override val killRadius: Double = 1000.0 * U.DRAW_SCALE/30.0,

But now I think we know that we want the value to be about 16, 32, , 64, the radii of the three missile sizes. The base will be 64.

class Asteroid(
    override var position: Point,
    val velocity: Velocity = U.randomVelocity(U.ASTEROID_SPEED),
    override val killRadius: Double = U.KILL_ASTEROID,


object U {
    const val KILL_ASTEROID = 64.0

Looks good. Saucer needs work.

class Saucer : ISpaceObject, InteractingSpaceObject, Collider {
    override lateinit var position: Point
    override val killRadius = U.KILL_SAUCER // was 200.0

    fun draw(drawer: Drawer) {
        drawer.translate(position)
        drawer.scale(U.DRAW_SCALE, U.DRAW_SCALE)
//        drawKillRadius(drawer)
        drawer.stroke = ColorRGBa.GREEN
        val sc = 2.0
        drawer.scale(sc, -sc)
        drawer.strokeWeight = 2.0 / U.DRAW_SCALE*sc
        drawer.lineStrip(saucerPoints)
    }

I believe that the Saucer’s base dimensions are -5 to +5 in length. I think its raw scale should be 2. And the stroke can be 1, unscaled for now.

    fun draw(drawer: Drawer) {
        drawer.translate(position)
//        drawKillRadius(drawer)
        drawer.stroke = ColorRGBa.GREEN
        drawer.scale(U.SCALE_SAUCER, -U.SCALE_SAUCER)
        drawer.strokeWeight = 1.0/U.SCALE_SAUCER
        drawer.lineStrip(saucerPoints)
    }

object U {
    const val KILL_ASTEROID = 64.0
    const val KILL_MISSILE = 1.0
    const val KILL_SAUCER = 10.0
    const val KILL_SHIP = 12.0
    const val SCALE_ASTEROID = 4.0
    const val SCALE_SHIP = 2.0
    const val SCALE_SAUCER = 2.0

Test. Looks decent, though I think the saucer is too small. We only have one size of saucer now but the small one wouldn’t be much bigger than a dot if this is the large one.

I’m going to set a standard stroke weight, 1.0, and have everyone use it.

    const val STROKE_ALL = 1.0

class Ship
    fun draw(drawer: Drawer) {
        drawer.translate(position)
//        drawKillRadius(drawer)
        drawer.strokeWeight = U.STROKE_ALL
        drawer.scale(U.SCALE_SHIP, U.SCALE_SHIP)
        drawer.scale(dropScale, dropScale)
        drawer.rotate(heading )
        drawer.stroke = ColorRGBa.WHITE
        drawer.lineStrip(points)
        if ( accelerating ) {
            displayAcceleration = (displayAcceleration + 1)%3
            if ( displayAcceleration == 0 ) {
                drawer.strokeWeight = 2.0*U.STROKE_ALL
                drawer.lineStrip(flare)
            }
        }
    }

class AsteroidView
    fun draw(asteroid: Asteroid, drawer: Drawer) {
        drawer.stroke = ColorRGBa.WHITE
        drawer.fill = null
        val sc = asteroid.scale()*U.SCALE_ASTEROID
        drawer.scale(sc,sc)
        drawer.rotate(asteroid.heading)
        drawer.stroke = ColorRGBa.WHITE
        drawer.strokeWeight = U.STROKE_ALL/sc
        drawer.scale(1.0, -1.0) // upside down
        drawer.lineStrip(rock)
    }

class Saucer
    fun draw(drawer: Drawer) {
        drawer.translate(position)
//        drawKillRadius(drawer)
        drawer.stroke = ColorRGBa.GREEN
        drawer.scale(U.SCALE_SAUCER, -U.SCALE_SAUCER)
        drawer.strokeWeight = U.STROKE_ALL/U.SCALE_SAUCER
        drawer.lineStrip(saucerPoints)
    }

Test game. It looks decent. Now to run the actual tests, which may not work so well because we’ve tweaked the kill radii. Five fail. Not too awful.

This fails:

    @Test
    fun `create game`() {
        val game = Game()
        val asteroid = Asteroid(Vector2(100.0, 100.0), Vector2(50.0, 50.0))
        val ship = Ship(
            position = Vector2(1000.0, 1000.0)
        )
        game.add(asteroid)
        game.add(ship)
        val trans = game.changesDueToInteractions()
        assertThat(trans.removes.size).isEqualTo(0)
        for (i in 1..12 * 60) game.tick(1.0 / 60.0)
        val x = asteroid.position.x
        val y = asteroid.position.y
        assertThat(x).isEqualTo(100.0 + 12 * 50.0, within(0.1))
        assertThat(y).isEqualTo(100.0 + 12 * 50.0, within(0.1))
        val trans2 = game.changesDueToInteractions()
        println(trans2.firstRemove())
        assertThat(trans2.removes.size).isEqualTo(2)
    }

It finds no element in trans2. Ah. This test actually flies the asteroid hoping to get it to crash into the ship. We need better coordinates. And I haven’t changed the acceleration and velocity figures so they must be somewhat scaled. It appears that this test is going to move the asteroid for 12 seconds and expects it to be at some random location.

Let’s see. If we move at 50 in x and y and we have 900 to go, why doesn’t it take 18 seconds to get right on top of the ship? Let’s begin by changing the 12s to 18s. And extracting a constant.

    @Test
    fun `create game`() {
        val game = Game()
        val asteroid = Asteroid(Vector2(100.0, 100.0), Vector2(50.0, 50.0))
        val ship = Ship(
            position = Vector2(1000.0, 1000.0)
        )
        game.add(asteroid)
        game.add(ship)
        val trans = game.changesDueToInteractions()
        assertThat(trans.removes.size).isEqualTo(0)
        val steps = (1000-100)/50.0
        for (i in 1..steps * 60) game.tick(1.0 / 60.0)
        val x = asteroid.position.x
        val y = asteroid.position.y
        assertThat(x).isEqualTo(100.0 + steps * 50.0, within(0.1))
        assertThat(y).isEqualTo(100.0 + steps * 50.0, within(0.1))
        val trans2 = game.changesDueToInteractions()
        println(trans2.firstRemove())
        assertThat(trans2.removes.size).isEqualTo(2)
    }

Test again. Green on that one. Next:

    @Test
    fun `saucer asteroid collision`() {
        val saucer = Saucer()
        saucer.position = Point(249.0, 0.0)
        val asteroid = Asteroid(Point.ZERO)
        val trans = Transaction()
        saucer.subscriptions.interactWithAsteroid(asteroid, trans)
        asteroid.subscriptions.interactWithSaucer(saucer, trans)
        assertThat(trans.removes).contains(asteroid)
        assertThat(trans.removes).contains(saucer)
    }

This test, and I think some of the others, is checking kill radius, so these values are way off.

    @Test
    fun `saucer asteroid collision`() {
        val saucer = Saucer() // kr = 10
        saucer.position = Point(73.0, 0.0)
        val asteroid = Asteroid(Point.ZERO) // kr = 64 tot = 74, test 73
        val trans = Transaction()
        saucer.subscriptions.interactWithAsteroid(asteroid, trans)
        asteroid.subscriptions.interactWithSaucer(saucer, trans)
        assertThat(trans.removes).contains(asteroid)
        assertThat(trans.removes).contains(saucer)
    }

Green. Next:

    @Test
    fun `ship randomizes position on hyperspace entry`() {
        val ship = Ship(U.CENTER_OF_UNIVERSE)
        val trans = Transaction()
        ship.enterHyperspace(trans)
        assertThat(trans.firstRemove()).isEqualTo(ship)
        ship.finalize()
        assertThat(ship.position).isNotEqualTo(U.CENTER_OF_UNIVERSE)
    }

The error is on finalize, and the message is “bound must be positive”. That’s due to this:

    fun randomInsideDouble() = 1000.0 + Random.nextDouble(UNIVERSE_SIZE-2000.0)

We want to be inside by 100, not 1000. Let’s make it a fraction of universe size so it won’t go wrong again.

    fun randomInsideDouble() = UNIVERSE_SIZE/10.0 + Random.nextDouble(UNIVERSE_SIZE-2* UNIVERSE_SIZE/10.0)

I expect that to go green. It does. Only two more, but I have to break.

Break

The above was Thursday, ending around 1030. Now it’s Friday, earlier than I’d like, but I’m awake. Let’s see what the tests are saying.

This fails:

    @Test
    fun `speed of light`() {
        val control = Controls()
        val ship = Ship(
            position = Vector2.ZERO,
            controls = control
        )
        ship.heading = -60.0 // northeast ish
        control.accelerate = true
        ship.update(100.0, Transaction()) // long time
        val v = ship.velocity
        val speed = v.length
        assertThat(speed).isEqualTo(5000.0, within(1.0))
        val radians60 = 60.0.asRadians
        val expected = Vector2(cos(radians60), -sin(radians60)) * 5000.0
        checkVector(v, expected, "velocity", 1.0)
    }

If this would check for speed of light, rather than a literal, it would have worked:

    fun `speed of light`() {
        val control = Controls()
        val ship = Ship(
            position = Vector2.ZERO,
            controls = control
        )
        ship.heading = -60.0 // northeast ish
        control.accelerate = true
        ship.update(100.0, Transaction()) // long time
        val v = ship.velocity
        val speed = v.length
        assertThat(speed).isEqualTo(U.SPEED_OF_LIGHT, within(1.0))
        val radians60 = 60.0.asRadians
        val expected = Vector2(cos(radians60), -sin(radians60)) * U.SPEED_OF_LIGHT
        checkVector(v, expected, "velocity", 1.0)
    }

Green. One more:

expected: Vector2(x=1320.0, y=1000.0)
 but was: Vector2(x=1026.0, y=1000.0)

From this:

    fun `missile starts ahead of ship`() {
        val sixtieth = 1.0 / 60.0
        val controls = Controls()
        val ship = Ship(
            position = Vector2(1000.0, 1000.0),
            controls = controls
        )
        ship.heading = 0.0
        controls.fire = true
        val missileOffset = Vector2(2 * 150.0 + 2 * 10.0, 0.0)
        var expectedPosition = ship.position + missileOffset.rotate(ship.heading)
        var additions = Transaction()
        ship.update(sixtieth, additions)
        assertThat(additions.adds).isNotEmpty
        var missile = additions.adds.first() as Missile
        print(missile.position)
        assertThat(missile.position).isEqualTo(expectedPosition)
        controls.fire = false
        additions = Transaction()
        ship.update(sixtieth, additions)
        assertThat(additions.adds).isEmpty()
        ship.heading = 90.0
        controls.fire = true
        expectedPosition = ship.position + missileOffset.rotate(ship.heading)
        additions = Transaction()
        ship.update(sixtieth, additions)
        assertThat(additions.adds).isNotEmpty
        missile = additions.adds.first() as Missile
        print(missile.position)
        assertThat(missile.position).isEqualTo(expectedPosition)
    }

Wow, who wrote that book? I think I must have been using that to figure out how to position the missile as well as checking the result. Not much by way of assertions there. Let’ at least comment the test, then maybe improve it. First, however, I want to get to green so that I can safely commit.

The failure is on the assert after the print. (Why is there still a print there? This code, honestly, it’s not my best work ever. I chalk most of it up to learning Kotlin, but it’s not a great test.)

The 150 and 10 there are the old Ship and Missile kill distances, if I’m not mistaken.

        val missileOffset = Vector2(2 * U.KILL_SHIP + 2 * U.KILL_MISSILE, 0.0)

Test. Green. Let’s at least comment the test while we’re looking at it. I can wait a bit before committing, no one else is even awake at this hour.

    @Test
    fun `missile starts ahead of ship`() {
        val sixtieth = 1.0 / 60.0
        val controls = Controls()
        val ship = Ship(
            position = Vector2(1000.0, 1000.0),
            controls = controls
        )
        ship.heading = 0.0
        controls.fire = true
        
        // hand calculate expected result
        val missileOffset = Vector2(2 * U.KILL_SHIP + 2 * U.KILL_MISSILE, 0.0)
        var expectedPosition = ship.position + missileOffset.rotate(ship.heading)
        
        // fire missile and check it
        var additions = Transaction()
        ship.update(sixtieth, additions)
        var missile = additions.firstAdd() as Missile
        assertThat(missile.position).isEqualTo(expectedPosition)
        
        // check that we cannot immediately fire again
        controls.fire = false
        additions = Transaction()
        ship.update(sixtieth, additions)
        assertThat(additions.adds).isEmpty()
        
        // check that we always position the missile in front of the ship
        ship.heading = 90.0
        controls.fire = true
        expectedPosition = ship.position + missileOffset.rotate(ship.heading)
        additions = Transaction()
        ship.update(sixtieth, additions)
        missile = additions.firstAdd() as Missile
        assertThat(missile.position).isEqualTo(expectedPosition)
    }

Well. I’m proud of myself for managing to do as much testing as I have. It’s not easy to test visual games like this one, and because you always want to check screen results, it gets tempting not to test with coded tests at all. At least it’s not easy and is tempting … for me. YMMV.

Let’s break this into multiple tests, OK? That should help with clarity more than a little.

Since there are three main testing phases, let’s make two more copies of the test and trim each one.

Reading further, I see that the second fire check is making sure that the ship doesn’t fire all the time, but only when fire is true. Weird test but OK.

    @Test
    fun `missile starts ahead of ship`() {
        val sixtieth = 1.0 / 60.0
        val controls = Controls()
        val ship = Ship(
            position = Vector2(1000.0, 1000.0),
            controls = controls
        )
        ship.heading = 0.0
        controls.fire = true

        // hand calculate expected result
        val missileOffset = Vector2(2 * U.KILL_SHIP + 2 * U.KILL_MISSILE, 0.0)
        val expectedPosition = ship.position + missileOffset.rotate(ship.heading)

        // fire missile and check it
        val additions = Transaction()
        ship.update(sixtieth, additions)
        val missile = additions.firstAdd() as Missile
        assertThat(missile.position).isEqualTo(expectedPosition)
    }

    @Test
    fun `missile starts ahead of ship when rotated`() {
        val sixtieth = 1.0 / 60.0
        val controls = Controls()
        val ship = Ship(
            position = Vector2(1000.0, 1000.0),
            controls = controls
        )

        // hand calculate expected result
        val missileOffset = Vector2(2 * U.KILL_SHIP + 2 * U.KILL_MISSILE, 0.0)

        // check that we always position the missile in front of the ship
        ship.heading = 90.0
        controls.fire = true
        val expectedPosition = ship.position + missileOffset.rotate(ship.heading)
        val additions = Transaction()
        ship.update(sixtieth, additions)
        val missile = additions.firstAdd() as Missile
        assertThat(missile.position).isEqualTo(expectedPosition)
    }

    @Test
    fun `does not fire if fire flag not set`() {
        val sixtieth = 1.0 / 60.0
        val controls = Controls()
        val ship = Ship(
            position = Vector2(1000.0, 1000.0),
            controls = controls
        )
        controls.fire = false
        val noMissile = Transaction()
        ship.update(sixtieth, noMissile)
        assertThat(noMissile.adds).isEmpty()
    }

Those are better, I think. We are green. Double check game play. Everything looks perfect except the Saucer is too small. Let’s commit, we have a lot of code hanging. Then we’ll chance the saucer.

Commit: Game space set to 1024, constants adjusted and mostly extracted to U.

Now the Saucer size. I just set its size to 4 rather than 2. We’ll deal with the small saucer later.

    const val KILL_SAUCER = 10.0
    const val SCALE_SAUCER = 4.0

Let’s consider all the KILL values compared to the size of the objects.

object size scale scaled kill
      size radius
saucer height=6 4 24 10
sm ast 8 4 32 16
md ast 8 8 64 32
lg ast 8 16 128 64
ship length=12 2 24 12


I turn on a display of all the kill radii:

kill radii displayed in red

The saucer is hard to hit, because its kill radius is less than its height and much less than its width. I’ll leave it, but put a comment on the constant.

    const val KILL_SAUCER = 10.0 // scaled dx=40 dy=24 suggests 12.0 Make it hard.

The others are nicely consistent with the size of the object. Accepted, committed, and somewhat happier than I was a couple of days ago.

I think it would be “nice” if the kill radii were computed from the actual sizes of the objects rather than provided literals, but this will do for now.

A note on my keyboard tray tells me to check ScoreKeeper. I think we left some ad hoc values in there.

The font size is set, perforce, in the main. It is 64. Let’s make that a U constant.

    const val FONT_SIZE = 64.0

    program {
        val font = loadFont("data/fonts/default.otf", U.FONT_SIZE)

And in ScoreKeeper lets try to adjust to that size. I create a couple of value in ScoreKeeper, lineSpace, which I set to the font size, and charSpace, which I just fiddle with until it’s about right at 30. Then I use those values throughout, with adjustments to make things look right. It’s still ad hoc but a bit more orderly.

class ScoreKeeper(var shipCount: Int = 3): ISpaceObject, InteractingSpaceObject {
    var totalScore = 0
    private val lineSpace = U.FONT_SIZE
    private val charSpace = 30.0 // random guess seems good

    override val subscriptions = Subscriptions(
        interactWithScore = { score, _ -> totalScore += score.score },
        draw = this::draw
    )

    override fun callOther(other: InteractingSpaceObject, trans: Transaction) =
        other.subscriptions.interactWithScoreKeeper(this, trans)

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

    fun draw(drawer: Drawer) {
        drawScore(drawer)
        drawFreeShips(drawer)
        drawGameOver(drawer)
    }

    private fun drawGameOver(drawer: Drawer) {
        if (shipCount >= 0) return
        drawer.isolated{
            stroke = ColorRGBa.WHITE
            translate(U.WINDOW_SIZE/2- 4.5*charSpace,U.WINDOW_SIZE/2 - 2*lineSpace)
            text("GAME OVER")
            translate(5.0, lineSpace) // looks better edged over
            scale(0.5, 0.5)
            text("Keyboard Controls")
            translate(0.0, lineSpace)
            text("d     - Spin Left")
            translate(0.0, lineSpace)
            text("f     - Spin Right")
            translate(0.0, lineSpace)
            text("j     - Accelerate")
            translate(0.0, lineSpace)
            text("k     - Fire Missile")
            translate(0.0, lineSpace)
            text("space - Hyperspace")
            translate(0.0, lineSpace)
            text("q     - Insert 25 Cents")
            translate(0.0, lineSpace)
            text("        for new game")
        }
    }

    private fun drawFreeShips(drawer: Drawer) {
        // numbers all adjusted by eye
        drawer.isolated {
            val ship = Ship(Point.ZERO)
            ship.heading = -90.0
            translate(0.0, lineSpace * 1.5)
            drawer.scale(1 / U.DROP_SCALE, 1 / U.DROP_SCALE)
            for (i in 1..shipCount) { // ships remaining
                translate(3*charSpace, 0.0)
                drawer.isolated {
                    ship.draw(drawer)
                }
            }
        }
    }

    private fun drawScore(drawer: Drawer) {
        drawer.isolated {
            translate(charSpace/2, lineSpace)
            stroke = ColorRGBa.GREEN
            fill = ColorRGBa.GREEN
            text(formatted(), Point(0.0, 0.0))
        }
    }

    fun formatted(): String = ("00000" + totalScore.toShort()).takeLast(5)

    fun takeShip(): Boolean {
        shipCount -= 1
        return shipCount >= 0
    }
}

You’d really like to have some decent font metrics here but this will do for now. We’re not as badly off as the Atari people, who had to draw their characters with vectors. Although at least then they’d know how big they were.

Should we emulate that? No.

Commit: Tweak ScoreKeeper using some new constants.

OK, I think we’re good to go. Let’s sum up.

Summary

We were — well, I was — unhappy with the ad hoc nature of scaling and kill radii. I made one fundamental decision: change the game’s internal scale to 1024, the better to match the Atari version, so that the Atari numbers could be more readily used in our game. After that, set the window size to be 1024, which gives us a scale factor of 1.0. We need to allow that to change in some future version.

Then we moved as many as we could find of the ad hoc scaling values up into Universe and used them consistently, resulting in a much more rational set of numbers:

object U {
    const val KILL_ASTEROID = 64.0
    const val SCALE_ASTEROID = 4.0
    const val KILL_MISSILE = 1.0
    const val KILL_SAUCER = 10.0 // scaled dx=40 dy=24 suggests 12.0 Make it hard.
    const val SCALE_SAUCER = 4.0
    const val KILL_SHIP = 12.0
    const val SCALE_SHIP = 2.0
    const val STROKE_ALL = 1.0
    const val DRAW_SCALE = 1.0
    const val UNIVERSE_SIZE = 1024.0
    const val WINDOW_SIZE = 1024
    const val FONT_SIZE = 64.0
    const val ASTEROID_SPEED = 100.0
    const val DROP_SCALE = 3.0
    const val MAKER_DELAY = 3.0
    const val SAFE_SHIP_DISTANCE = UNIVERSE_SIZE/10.0
    const val SAUCER_SPEED = 150.0
    const val SAUCER_LIFETIME = 10.0
    const val SPEED_OF_LIGHT = 500.0
    const val SPLAT_LIFETIME = 2.0
    const val SHIP_ROTATION_SPEED = 200.0 // degrees per second
    val SHIP_ACCELERATION = Velocity(120.0, 0.0)
    val CENTER_OF_UNIVERSE = Point(UNIVERSE_SIZE / 2, UNIVERSE_SIZE / 2)

We could probably improve these names further, but they’re serving well.

Only the asteroids change scale dynamically, down from 128 to 64 and to 32. Their code adjusts dynamically when the asteroid splits. The saucer needs a smaller size: we have s story card for that.

There is still some ad hoc display code in the ScoreKeeper, but that is mostly the nature of the beast: it’s adjusted to look good, not according to any particular universal notions. Even there, the code is based on a couple of standard values and multiples thereof.

I feel far less confused when I try to think about the scaling now. It’s simplified and centralized, and I think we are to the point where the only thing that can change the scaling is if we change the window size. I have a note to allow that as well.

I think the only thing that I have a slight glitch when I think about it is the relationship between the object’s minimal drawn form, its scale, and its kill radius. The issue there is simply that the kill radii for the ship and the saucer are both tweaked a bit to adjust for their different dimensions in x vs y, unlike the asteroids, which are basically round. But at least now I can get through a look at all the scaling without despairing. I think that’s good progress.

Enough for now. See you next time!