Kotlin 263 - Moar Asteroids
GitHub Decentralized Repo
GitHub Centralized Repo
GitHub Flat Repo
After yesterday’s social concerns, I’d like to turn my attention back to happier matters, the kill-or-be-killed environment of the intrepid men and women who shoot down asteroids in the interest of humankind.
Last time, if memory serves, we displayed the score, and modified the game so that when it discovers the ship to be in active, it waits a little while and then activates it at screen center. It goes like this:
var ShipGoneFor = 0.0
fun checkIfShipNeeded(deltaTime: Double) {
if ( ! Ship.active ) {
ShipGoneFor += deltaTime
if (ShipGoneFor > 4.0) {
Ship.position = Vector2(U.ScreenWidth/2.0, U.ScreenHeight/2.0)
Ship.velocity = Vector2(0.0,0.0)
Ship.active = true
ShipGoneFor = 0.0
}
}
}
At this point, you never run out of ships, which means that all you would need is a lot of patience to get a high score … except that we don’t replenish the asteroids after you kill them. You’re left alone in the dark silence of outer space.
Let’s begin the day by noting that there are no live asteroids and creating new ones. There are supposed to be more and more each round, up to 11, in the sequence 4,6,8,10,11,11.
Curses!
I really want to just type this in. And I really already owe you, and the code, some tests. I reach deep within my soul … do I have the gumption to write a test for this? Or will I just implement it?
We know what I believe, which is that doing it test first will make it go better, and will give me confidence that it works as intended. We also know that I surely can just go ahead and implement it. It’ll take one more top-level variable than the ship check, and we’ll need some kind of game over display. It’ll be easy to do. I’m sure we can just do it.
No. OK, I’ll do a test. I’ve talked myself into it in an odd way: I think it’ll be a bit tricky to write, so there’ll be more than just tedium there, there will be a bit of problem solving as well.
I make a new test class:
class GameTests {
@Test
fun `game starts with four asteroids`() {
}
}
This will be semi-interesting on its own. How does the game start now?
program {
// val image = loadImage("data/images/pm5544.png")
val font = loadFont("data/fonts/default.otf", 64.0)
createGame(U.MissileCount,U.AsteroidCount)
startGame(width, height)
fun startGame(width: Int, height: Int) {
Ship.active = true
Ship.position = Vector2(width/2.0, height/2.0)
activateAsteroids(4)
}
Nice. We can test that.
@Test
fun `game starts with four asteroids`() {
createGame(U.MissileCount, U.AsteroidCount)
startGame(U.ScreenWidth, U.ScreenHeight)
assertThat(activeAsteroids(SpaceObjects).size).isEqualTo(4)
}
Well then. For the next test we want to clear all the asteroids, call the as yet unwritten check function, and find six.
fun `second wave is six`() {
createGame(U.MissileCount, U.AsteroidCount)
startGame(U.ScreenWidth, U.ScreenHeight)
clearAsteroids()
checkIfAsteroidsNeeded(0.1)
assertThat(activeAsteroids(SpaceObjects).size).isEqualTo(0)
checkIfAsteroidsNeeded(4.1)
assertThat(activeAsteroids(SpaceObjects).size).isEqualTo(6)
}
The asteroid delay is supposed to be 4 seconds. Let’s make that an official Universe value.
object U {
const val AsteroidCount = 26
const val AsteroidSpeed = 100.0
const val AsteroidWaveDelay = 4.0
fun `second wave is six`() {
createGame(U.MissileCount, U.AsteroidCount)
startGame(U.ScreenWidth, U.ScreenHeight)
clearAsteroids()
checkIfAsteroidsNeeded(0.1)
assertThat(activeAsteroids(SpaceObjects).size).isEqualTo(0)
checkIfAsteroidsNeeded(U.AsteroidWaveDelay+0.1)
assertThat(activeAsteroids(SpaceObjects).size).isEqualTo(6)
}
That will demand our new functions. I intend that clearAsteroids
is a helper in the test:
private fun clearAsteroids()
= activeAsteroids(SpaceObjects).forEach { it.active = false }
The other goes with Game:
var AsteroidsGoneFor = 0.0
fun checkIfAsteroidsNeeded(deltaTime: Double) {
if (activeAsteroids(SpaceObjects).isEmpty()) {
AsteroidsGoneFor += deltaTime
if (AsteroidsGoneFor > U.AsteroidWaveDelay) {
AsteroidsGoneFor = 0.0
activateAsteroids(nextWaveSize())
}
}
}
Now I need a new function, and an adjustment to activateAsteroids
:
var CurrentWaveSize = 0
fun nextWaveSize(): Int = min(CurrentWaveSize +2,11)
private fun activateAsteroids(asteroidCount: Int) {
deactivateAsteroids()
CurrentWaveSize = asteroidCount
for (i in 1..asteroidCount) activateAsteroidAtEdge()
}
I think my test should pass. And it does. And now that I have the nextWaveSize
tested for being used, I can just check it for accuracy.
fun `check wave sizes`() {
CurrentWaveSize = 4
assertThat(nextWaveSize()).isEqualTo(6)
CurrentWaveSize = 6
assertThat(nextWaveSize()).isEqualTo(8)
CurrentWaveSize = 8
assertThat(nextWaveSize()).isEqualTo(10)
CurrentWaveSize = 10
assertThat(nextWaveSize()).isEqualTo(11)
CurrentWaveSize = 11
assertThat(nextWaveSize()).isEqualTo(11)
}
That’s green, but I think it’d be better if it took the current size as a parameter rather than using the global.
fun `check wave sizes`() {
assertThat(nextWaveSize(4)).isEqualTo(6)
assertThat(nextWaveSize(6)).isEqualTo(8)
assertThat(nextWaveSize(8)).isEqualTo(10)
assertThat(nextWaveSize(10)).isEqualTo(11)
assertThat(nextWaveSize(11)).isEqualTo(11)
}
That demands that I change the function and its other caller:
var AsteroidsGoneFor = 0.0
fun checkIfAsteroidsNeeded(deltaTime: Double) {
if (activeAsteroids(SpaceObjects).isEmpty()) {
AsteroidsGoneFor += deltaTime
if (AsteroidsGoneFor > U.AsteroidWaveDelay) {
AsteroidsGoneFor = 0.0
activateAsteroids(nextWaveSize(CurrentWaveSize))
}
}
}
var CurrentWaveSize = 0
fun nextWaveSize(previousSize: Int): Int = min(previousSize +2,11)
I’ve added a call to the check in the game cycle, so if this test runs, I can try the game.
Tests are green as your finger under your Captain Midnight Secret Decoder Ring. Try the game.
It works … and I find two defects! First, when the ship is recreated, its rotation isn’t set to zero. Second, the asteroids come out tiny, because I’ve crunched them all down.
We have no test for the ship creation yet. I’ll just fix the bug.
if (ShipGoneFor > 4.0) {
Ship.position = Vector2(U.ScreenWidth/2.0, U.ScreenHeight/2.0)
Ship.velocity = Vector2(0.0,0.0)
Ship.angle = 0.0
Ship.active = true
ShipGoneFor = 0.0
}
And I’ll test the size with a new test.
fun `new wave is full size`() {
createGame(U.MissileCount, U.AsteroidCount)
startGame(U.ScreenWidth, U.ScreenHeight)
clearAsteroids()
checkIfAsteroidsNeeded(0.1)
checkIfAsteroidsNeeded(4.1)
assertThat(activeAsteroids(SpaceObjects).size).isEqualTo(6)
activeAsteroids(SpaceObjects).forEach {
assertThat(it.scale).isEqualTo(4.0)
}
}
This ought to fail. It does:
expected: 4.0
but was: 1.0
Fix it:
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()
}
}
This should pass the test and make the game right. Green, and I get six big asteroids in the second wave. Let’s commit: Asteroid waves implemented.
I think I noticed another issue. In fact I did notice another issue. It appears that the ship and asteroid do not collide until the center of the ship overlaps the asteroid.
Let’s see how we check that collision. We’re using a radius of 12 and that’s just wrong. 24 works better.
object U {
const val AsteroidCount = 26
const val AsteroidKillRadius = 16.0
const val AsteroidSpeed = 100.0
const val AsteroidWaveDelay = 4.0
const val LightSpeed = 500.0
const val MissileCount = 6
const val MissileOffset = 50.0
const val MissileSpeed = LightSpeed / 3.0
const val MissileTime = 3.0
const val ScreenHeight = 1024
const val ScreenWidth = 1024
const val ShipKillRadius = 24.0
const val ShipDeltaV = 120.0
}
private fun collidingShip(asteroid: SpaceObject, ship: SpaceObject): Boolean {
val asteroidSize = U.AsteroidKillRadius*asteroid.scale
val shipSize = U.ShipKillRadius
return asteroid.position.distanceTo(ship.position) < asteroidSize+shipSize
}
I did the same for missile kill radius.
24 is a bit large but we’ll leave it for now. Commit: Adjust ship kill radius, move values to U.
Given that I’ve worked out how to test the new wave, it should be straightforward to write a test for ship restarting, at least the detection and timing thereof. We don’t have a limited ship count yet.
@Test
fun `ship refresh`() {
createGame(U.MissileCount, U.AsteroidCount)
startGame(U.ScreenWidth, U.ScreenHeight)
Ship.active = false
checkIfShipNeeded(0.1)
checkIfShipNeeded(U.ShipDelay + 0.1)
assertThat(Ship.active).isEqualTo(true)
}
That’s green. Commit: added test for ship refresh.
Let’s reflect and sum up.
Reflection
I’m proud of myself for bearing down and doing the tests. They turned out to be easy enough to do and they drove me nicely through the necessary code. I feel like a better person today.
There are some issues to think about. I’ve put top-level vars in to keep track of the time delays and current number of asteroids:
var CurrentWaveSize = 0
fun nextWaveSize(previousSize: Int): Int = min(previousSize +2,11)
var ShipGoneFor = 0.0
fun checkIfShipNeeded(deltaTime: Double) {
if ( ! Ship.active ) {
ShipGoneFor += deltaTime
if (ShipGoneFor > U.ShipDelay) {
Ship.position = Vector2(U.ScreenWidth/2.0, U.ScreenHeight/2.0)
Ship.velocity = Vector2(0.0,0.0)
Ship.angle = 0.0
Ship.active = true
ShipGoneFor = 0.0
}
}
}
This isn’t generally good practice, but it’s not all that inconsistent with trying to write a tight procedural game. I could make them private. Kotlin would ask me to remove the caps on their names if I do that. Let’s do.
private var currentWaveSize = 0
fun nextWaveSize(previousSize: Int): Int = min(previousSize +2,11)
private var shipGoneFor = 0.0
fun checkIfShipNeeded(deltaTime: Double) {
if ( ! Ship.active ) {
shipGoneFor += deltaTime
if (shipGoneFor > U.ShipDelay) {
Ship.position = Vector2(U.ScreenWidth/2.0, U.ScreenHeight/2.0)
Ship.velocity = Vector2(0.0,0.0)
Ship.angle = 0.0
Ship.active = true
shipGoneFor = 0.0
}
}
}
The “organization” of the functions in the files is getting pretty random. Here’s the beginning of the Game.kt file with the names folded:
Pretty random, isn’t it?
I may have to resort to alphabetizing. Will IDEA do that for me? No. It’ll sort the lines, which isn’t all that handy just now. Anyway, things are getting messy and it’s taking me longer to find things that I’m looking for. Something needs to be done.
However, setting aside the issue of finding things, the code actually makes pretty good sense to me. Take this for example:
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()
drawScore(drawer)
checkIfShipNeeded(deltaTime)
checkIfAsteroidsNeeded(deltaTime)
}
Let’s do two extracts and make this even better.
fun gameCycle(
spaceObjects: Array<SpaceObject>,
width: Int,
height: Int,
drawer: Drawer,
deltaTime: Double
) {
updateEverything(spaceObjects, deltaTime, width, height)
drawEverything(spaceObjects, drawer)
checkCollisions()
drawScore(drawer)
checkIfShipNeeded(deltaTime)
checkIfAsteroidsNeeded(deltaTime)
}
private fun updateEverything(
spaceObjects: Array<SpaceObject>,
deltaTime: Double,
width: Int,
height: Int
) {
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)
}
}
private fun drawEverything(spaceObjects: Array<SpaceObject>, drawer: Drawer) {
for (spaceObject in spaceObjects) {
if (spaceObject.active) draw(spaceObject, drawer)
}
}
Even better. As long as we use lots of functions with sensible names, the code remains pretty clear. If the program were ten times bigger, I think we’d have trouble sorting things out, but at this scale it’s not too bad. I prefer the objects, even though this version seems to have fewer total lines of code.
I think we’ve done enough good today. We’ll wrap up, but overall, I am pleased with the day and with how the game is evolving.
See you next time!