Kotlin 266 - Saucer
GitHub Decentralized Repo
GitHub Centralized Repo
GitHub Flat Repo
I think it’s time to start on the saucer. What do you think?
Story
The saucer appears every seven seconds and runs for seven seconds. It starts at a random y coordinate, at the edge of the screen. It alternates traveling left to right and right to left. It changes direction every so often: I’ll have to look up the interval. It can fire only two missiles at a time, and I think it fires once per second. I’ll have to look that up. It has some rule about sometimes firing accurately and sometimes not. I’ll have to look up the odds. None those values should change the basic logic.
Plan
I figure it’ll work much the same as the Ship. If there is no saucer, start a countdown and activate it. Keep a direction value, plus or minus, probably plus or minus its basic speed. Keep three running timers, one for firing, one for stopping, and one for turning. Probably the stopping one and starring one can be the same.
The saucer’s point list is available from the other versions, and drawing it should be the same as everything else.
Tests
I think I’d be wise to start with tests and let them pull out the functionality we need.
Let’s start with something simple, starting it and letting it motor across.
Get To It
@Test
fun `start saucer after seven seconds`() {
createGame(U.MissileCount, U.AsteroidCount)
startGame(U.ScreenWidth, U.ScreenHeight)
assertThat(Saucer.active).isEqualTo(false)
}
That’s more than enough to drive out a Saucer.
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()
}
We’ll add it here. I need a new global of course.
var Score: Int = 0
lateinit var SpaceObjects: Array<SpaceObject>
lateinit var Ship: SpaceObject
lateinit var Saucer: SpaceObject
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)
Saucer = newSaucer()
objects.add(Saucer)
for (i in 1..asteroidCount) objects.add(newAsteroid())
SpaceObjects = objects.toTypedArray()
}
Now …
private fun newSaucer(): SpaceObject = SpaceObject(SpaceObjectType.SAUCER, 0.0, 0.0, 0.0, 0.0, 0.0, false)
Nasty but they all are and we only do them once. Now we need the enum, which will in turn drive out the points.
private val saucerPoints = listOf(
Vector2(-2.0, 1.0), Vector2(2.0, 1.0), Vector2(5.0, -1.0),
Vector2(-5.0, -1.0), Vector2(-2.0, -3.0), Vector2(2.0, -3.0),
Vector2(5.0, -1.0), Vector2(2.0, 1.0), Vector2(1.0, 3.0),
Vector2(-1.0, 3.0), Vector2(-2.0, 1.0), Vector2(-5.0, -1.0),
Vector2(-2.0, 1.0)
)
enum class SpaceObjectType(val points: List<Vector2>) {
ASTEROID(asteroidPoints),
SHIP(shipPoints),
SAUCER(saucerPoints),
MISSILE(missilePoints)
}
I think this test might run now. If not, it’ll tell me what to do. Yes. Commit: initial creation of saucer object, points, enum, etc.
Now enhance the test to demand the little bugger into existence.
@Test
fun `start saucer after seven seconds`() {
createGame(U.MissileCount, U.AsteroidCount)
startGame(U.ScreenWidth, U.ScreenHeight)
assertThat(Saucer.active).isEqualTo(false)
checkIfSaucerNeeded(0.1)
assertThat(Saucer.active).isEqualTo(false)
checkIfSaucerNeeded(U.SaucerDelay + 0.1)
assertThat(Saucer.active).isEqualTo(true)
}
Now we’d best implement that check. I’ll do the minimum:
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
if ( ! Saucer.active ) {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay) {
Saucer.active = true
saucerGoneFor = 0.0
}
}
}
I expect the test to run. It does. I can’t resist running the program to see if the saucer displays. I expect it to be too small. I see a corner of it in the top left corner. Let’s enhance the init.
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
if ( ! Saucer.active ) {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay) {
Saucer.active = true
Saucer.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
Saucer.velocity = Vector2(U.SaucerSpeed, 0.0)
saucerGoneFor = 0.0
}
}
}
That demands the speed. I have no idea but the other version has, 150.0.
One minor detail, it’s upside down.
We can fix the data, or fix the code. I change the sign of all the y coordinates in the saucer and it is just fine. And the scale looks OK, but I think the scale between this version and the others is different for the ship and saucer both. Anyway it looks good enough for now.
Let’s make the saucer go away again. I’ll enhance the test further:
@Test
fun `start saucer after seven seconds`() {
createGame(U.MissileCount, U.AsteroidCount)
startGame(U.ScreenWidth, U.ScreenHeight)
assertThat(Saucer.active).isEqualTo(false)
checkIfSaucerNeeded(0.1)
assertThat(Saucer.active).isEqualTo(false)
checkIfSaucerNeeded(U.SaucerDelay + 0.1)
assertThat(Saucer.active).isEqualTo(true)
checkIfSaucerNeeded(U.SaucerDelay + 0.1)
assertThat(Saucer.active).isEqualTo(false)
}
That’ll fail on the last assert. It does. Fix:
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
if ( ! Saucer.active ) {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay) {
Saucer.active = true
Saucer.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
Saucer.velocity = Vector2(U.SaucerSpeed, 0.0)
saucerGoneFor = 0.0
}
} else {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay ) {
Saucer.active = false
}
}
}
I expect that to run green. It does. I want to watch it in the game.
Bug! I forgot to zero the time. Enhance the test:
@Test
fun `start saucer after seven seconds`() {
createGame(U.MissileCount, U.AsteroidCount)
startGame(U.ScreenWidth, U.ScreenHeight)
assertThat(Saucer.active).isEqualTo(false)
checkIfSaucerNeeded(0.1)
assertThat(Saucer.active).isEqualTo(false)
checkIfSaucerNeeded(U.SaucerDelay + 0.1)
assertThat(Saucer.active).isEqualTo(true)
checkIfSaucerNeeded(U.SaucerDelay + 0.1)
assertThat(Saucer.active).isEqualTo(false)
checkIfSaucerNeeded( 0.1)
assertThat(Saucer.active).describedAs("stays gone").isEqualTo(false)
}
Should fail. Does. Fix:
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
if ( ! Saucer.active ) {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay) {
saucerGoneFor = 0.0
Saucer.active = true
Saucer.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
Saucer.velocity = Vector2(U.SaucerSpeed, 0.0)
}
} else {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay ) {
saucerGoneFor = 0.0
Saucer.active = false
}
}
}
Should be green. Game shows no saucer, then saucer, then no saucer, then saucer again. Always left to right, of course. Let’s refactor that code a bit:
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay) { saucerGoneFor = 0.0
if ( ! Saucer.active ) {
Saucer.active = true
Saucer.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
Saucer.velocity = Vector2(U.SaucerSpeed, 0.0)
}
} else {
Saucer.active = false
}
}
I moved things around, had to do it by hand. Should still pass. Doesn’t. Undo it. Do this much:
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
saucerGoneFor += deltaTime
if ( ! Saucer.active ) {
if (saucerGoneFor > U.SaucerDelay) {
saucerGoneFor = 0.0
Saucer.active = true
Saucer.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
Saucer.velocity = Vector2(U.SaucerSpeed, 0.0)
}
} else {
if (saucerGoneFor > U.SaucerDelay ) {
saucerGoneFor = 0.0
Saucer.active = false
}
}
}
OK how can we do this without error? Let’s insert a redundant check:
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay) {
if (!Saucer.active) {
if (saucerGoneFor > U.SaucerDelay) {
saucerGoneFor = 0.0
Saucer.active = true
Saucer.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
Saucer.velocity = Vector2(U.SaucerSpeed, 0.0)
}
} else {
if (saucerGoneFor > U.SaucerDelay) {
saucerGoneFor = 0.0
Saucer.active = false
}
}
}
}
Test. Green. Remove redundant check:
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay) {
if (!Saucer.active) {
if (saucerGoneFor > U.SaucerDelay) {
saucerGoneFor = 0.0
Saucer.active = true
Saucer.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
Saucer.velocity = Vector2(U.SaucerSpeed, 0.0)
}
} else {
saucerGoneFor = 0.0
Saucer.active = false
}
}
}
Test. Green. Remove the other.
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay) {
if (!Saucer.active) {
saucerGoneFor = 0.0
Saucer.active = true
Saucer.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
Saucer.velocity = Vector2(U.SaucerSpeed, 0.0)
} else {
saucerGoneFor = 0.0
Saucer.active = false
}
}
}
Test. Green. One more little change:
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay) {
saucerGoneFor = 0.0
if (!Saucer.active) {
Saucer.active = true
Saucer.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
Saucer.velocity = Vector2(U.SaucerSpeed, 0.0)
} else {
Saucer.active = false
}
}
}
Test. Still green. I’m glad I wrote that nice test. Commit: ship starts after seven seconds, stops, starts, etc.
I should have committed when it first worked, before undertaking the refactoring. Anyway, we have a scary saucer flying. Should we make it change direction before we close? Let’s do.
I’ll write another test.
@Test
fun `saucer switches direction`() {
createGame(U.MissileCount, U.AsteroidCount)
startGame(U.ScreenWidth, U.ScreenHeight)
checkIfSaucerNeeded(U.SaucerDelay + 0.1)
assertThat(Saucer.active).isEqualTo(true)
assertThat(Saucer.dx).isGreaterThan(0.0)
checkIfSaucerNeeded(U.SaucerDelay + 0.1)
assertThat(Saucer.active).isEqualTo(false)
checkIfSaucerNeeded(U.SaucerDelay + 0.1)
assertThat(Saucer.active).isEqualTo(true)
assertThat(Saucer.dx).describedAs("reverse").isLessThan(0.0)
}
Should fail on reverse. Does:
java.lang.AssertionError: [reverse]
Expecting actual:
150.0
to be less than
Now to make it happen.
private var saucerSpeed = U.SaucerSpeed
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay) {
saucerGoneFor = 0.0
if (!Saucer.active) {
Saucer.active = true
Saucer.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
Saucer.velocity = Vector2(saucerSpeed, 0.0)
saucerSpeed *= -1.0
} else {
Saucer.active = false
}
}
}
I expect green. I get green. Commit: Saucer alternates direction. Does not turn.
I think that’ll do for this afternoon. Let’s sum up.
Summary
Creating tests for the Saucer made it easy. It went in just fine until I did something wrong with that refactoring. Reverting and going in smaller steps worked fine.
I think I could have committed more frequently, and had I committed before the refactoring, reversion would have been easier. As it was, I had to manually undo, typing all those tedious Command+Zs. I am exhausted.
It went well. And there’s now a bit of duplication that we could think about removing:
var dropScale = U.ShipDropInScale
private var shipGoneFor = 0.0
fun checkIfShipNeeded(deltaTime: Double) {
if ( ! Ship.active ) {
shipGoneFor += deltaTime
if (shipGoneFor > U.ShipDelay) {
dropScale = U.ShipDropInScale
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
}
} else {
dropScale = max(dropScale - U.ShipDropInScale*deltaTime, 1.0)
}
}
private var saucerSpeed = U.SaucerSpeed
private var saucerGoneFor = 0.0
fun checkIfSaucerNeeded(deltaTime: Double) {
saucerGoneFor += deltaTime
if (saucerGoneFor > U.SaucerDelay) {
saucerGoneFor = 0.0
if (!Saucer.active) {
Saucer.active = true
Saucer.position = Vector2(0.0, Random.nextDouble(U.ScreenHeight.toDouble()))
Saucer.velocity = Vector2(saucerSpeed, 0.0)
saucerSpeed *= -1.0
} else {
Saucer.active = false
}
}
}
I’m not at all sure that I’ll go after that, especially since the saucer one is going to get more tricky. Although … what if we had a new function, checkIfSaucerTurns
?
Anyway in this version we can’t readily extract an object. We’ll probably let the duplication be.
Anyway, a nice new feature and we have a saucer.
See you next time!