Kotlin 132: Where should we be?
In Asteroids, after your ship dies, if you are allowed any more, a new one appears … somewhere. Let’s see how to make that happen.
I’ll review my Codea Lua version to see if I can glean the spec from the code. I see a couple of methods that seem germane:
function Ship:safeToAppear()
for k,o in pairs(U.objects) do
if self:inRange(o) then return false end
end
return true
end
function Ship:tryToAppear()
if self:safeToAppear() then
U:addObject(self)
self:dropIn()
else
tween.delay(3,self.tryToAppear,self)
end
end
function Ship:dropIn()
self.scale = 10
tween(1, self, {scale=2})
end
The ship always appears at screen center (I had been thinking that it was random) and only appears if there’s nothing too close. The function inRange
is the same as we use for collisions, actual distance < the sum of the kill radii of the two objects in question.
The ship reappears after 3 seconds, unless it can’t, in which case it seems to wait another three seconds. Note that it is possible to appear with something about to hit you: just not if it would hit you the instant you appear.
The dropIn
function is supposed to make a largish ship appear and seem to drop down onto the screen. I suppose we should do that as well. Running my game on the iPad, I don’t see that effect working. Why has no one sent me a bug report?
There are more details that I see in the Lua code: I see that Score spawns the ships, presumably ScoreKeeper in our version, because it knows how many ships you have. A small issue for us is that we don’t have the ability to find any of our special flying objects, but we’ll sort something out. Currently we’re not creating a new ship at all, so one possibility is for it to know how many copies it can make. And ShipMonitor is where all our action needs to take place.
Enough history, let’s design.
Design? Up front?? Are you MAD???
Of course design up front. We think about design all the time and one good time is before we type in code, lest we just type it into some random spot. Our first design action should be to study the ShipMonitor, because it knows when the ship is gone.
class ShipMonitor(val ship: Flyer) : IFlyer {
override val mutuallyInvulnerable = false
override val killRadius: Double = -Double.MAX_VALUE
var state: ShipMonitorState = HaveSeenShip
override fun interactWith(other: IFlyer): List<IFlyer> {
if (state == LookingForShip) {
if (other == ship)
state = HaveSeenShip
}
return emptyList() // no damage done here
}
override fun interactWithOther(other: IFlyer): List<IFlyer> {
return interactWith(other)
}
override fun update(deltaTime: Double): List<IFlyer> {
var toBeCreated: List<IFlyer> = emptyList()
state = when (state) {
HaveSeenShip -> LookingForShip
LookingForShip -> {
toBeCreated = listOf(ship)
HaveSeenShip
}
}
return toBeCreated
}
}
So. This object interacts with all the other objects. In update
, it generally starts in LookingForShip
state and if it sees a ship goes to HaveSeenShip
state. Currently, if it enters update
still in LookingForShip
, it takes the ship (which it was created to know) and returns it, to be added back into the mix.
I think we’ll need a third state, maybe WaitingToCreate
. When we enter update with Looking
still set, we’ll set Waiting
and set a time. Thereafter, in update
, we’ll just stay in Waiting
until the timer elapses, and, for now, pop the ship back in the mix.
That makes sense to me. Let’s try it. Do we have some tests we could build on? I certainly hope so.
I have good news and bad news. The tests are extensive, and they are story tests, which makes them pretty long. Let’s just see if we can write one that isn’t quite so long. It may require us to add some capabilities to the ShipMonitor to let us get at it. We’ll see.
I am also almost certain that this test will break the others. We’ll decide what to do about that in due time.
Here’s what I think we want to test.
- Call
update
, not finding a ship. State should beLooking
. - Call
update
again, state should beWaiting
. - Call
update
again, three seconds later, update should return a ship, stateHaveSeen
.
Instead of setting up all the flyers, I think we can just call update
repeatedly and check its behavior.
@Test
fun `delayed creation of ship`() {
val ship = Flyer.ship(Point(10.0, 10.0))
val mon = ShipMonitor(ship)
assertThat(mon.state).isEqualTo(ShipMonitorState.HaveSeenShip)
mon.update(1.0/60.0)
assertThat(mon.state).isEqualTo(ShipMonitorState.LookingForShip)
mon.update(1.0/60.0)
assertThat(mon.state).isEqualTo(ShipMonitorState.WaitingToCreate)
}
I am reliably informed by IDEA that there is no state WaitingToCreate. IDEA adds it for me. Now I expect the test to fail on the last assertion. Let’s see what happens.
What happens is that it won’t compile because my when
statements aren’t exhaustive. Just another red bar, these strict languages are so helpful.
override fun update(deltaTime: Double): List<IFlyer> {
var toBeCreated: List<IFlyer> = emptyList()
state = when (state) {
HaveSeenShip -> LookingForShip
LookingForShip -> {
toBeCreated = listOf(ship)
HaveSeenShip
}
}
return toBeCreated
}
How convenient, this is just where we would have had to make our change anyway. I guess we’ll just go all the way … well, most of the way. I type this:
override fun update(deltaTime: Double): List<IFlyer> {
var toBeCreated: List<IFlyer> = emptyList()
state = when (state) {
HaveSeenShip -> LookingForShip
LookingForShip -> WaitingToCreate
WaitingToCreate -> {
toBeCreated = listOf(ship)
HaveSeenShip
}
}
return toBeCreated
}
I think the test should pass … and that if we were to look, it would have returned a ship. Test.
My current test passes, and sure enough, one of the others fails, because it expects a ship right away. I’m going to just comment them out as they fail and decide when we’re done here what to do about them.
Green. Now to upgrade the new test to deal with the delay:
@Test
fun `delayed creation of ship`() {
val ship = Flyer.ship(Point(10.0, 10.0))
val mon = ShipMonitor(ship)
assertThat(mon.state).isEqualTo(ShipMonitorState.HaveSeenShip)
var created = mon.update(1.0/60.0)
assertThat(mon.state).isEqualTo(ShipMonitorState.LookingForShip)
assertThat(created).isEmpty()
created = mon.update(1.0/60.0)
assertThat(mon.state).isEqualTo(ShipMonitorState.WaitingToCreate)
assertThat(created).isEmpty()
created = mon.update(1.0)
assertThat(mon.state).isEqualTo(ShipMonitorState.WaitingToCreate)
assertThat(created).describedAs("too soon").isEmpty()
created = mon.update(2.1)
assertThat(mon.state).isEqualTo(ShipMonitorState.WaitingToCreate)
assertThat(created).describedAs("on time").contains(ship)
}
I expect “too soon”. I get this:
expected: WaitingToCreate
but was: HaveSeenShip
If I had put the other test first it would have said too soon. I’ll describe both:
created = mon.update(1.0)
assertThat(mon.state).describedAs("too soon").isEqualTo(ShipMonitorState.WaitingToCreate)
assertThat(created).describedAs("too soon").isEmpty()
created = mon.update(2.1)
assertThat(mon.state).describedAs("on time").isEqualTo(ShipMonitorState.WaitingToCreate)
assertThat(created).describedAs("on time").contains(ship)
Test again demanding “too soon”. Ha:
[too soon]
expected: WaitingToCreate
but was: HaveSeenShip
Perfect. So we need our state machine to set and check some time.
override fun update(deltaTime: Double): List<IFlyer> {
elapsedTime += deltaTime
var toBeCreated: List<IFlyer> = emptyList()
state = when (state) {
HaveSeenShip -> LookingForShip
LookingForShip -> {
elapsedTime = 0.0
WaitingToCreate
}
WaitingToCreate -> {
if (elapsedTime >= 3.0) {
toBeCreated = listOf(ship)
HaveSeenShip
} else WaitingToCreate
}
}
return toBeCreated
}
I rather expect this to work. It doesn’t, because the test is wrong:
[on time]
expected: WaitingToCreate
but was: HaveSeenShip
In the same cycle as when we create the ship, we have to say that we’ve seen it, to get back to nominal. So the test needs to say:
created = mon.update(1.0)
assertThat(mon.state).describedAs("too soon").isEqualTo(ShipMonitorState.WaitingToCreate)
assertThat(created).describedAs("too soon").isEmpty()
created = mon.update(2.1)
assertThat(mon.state).describedAs("on time").isEqualTo(ShipMonitorState.HaveSeenShip)
assertThat(created).describedAs("on time").contains(ship)
We’ll be green now, I think. Green. I’m sure the game will reflect this but I can’t resist running it. Works as advertised. Which reminds me, we should be setting the ship to the middle of the screen, so let’s add that to the test.
created = mon.update(2.1)
assertThat(mon.state).describedAs("on time").isEqualTo(ShipMonitorState.HaveSeenShip)
assertThat(created).describedAs("on time").contains(ship)
assertThat(ship.position).isEqualTo(Point(5000.0, 5000.0))
Should fail. Does:
expected: Vector2(x=5000.0, y=5000.0)
but was: Vector2(x=10.0, y=10.0)
It seems to me that that Point(5000.0, 5000.0) counts as a magic number. We don’t have any really good handling for those constants yet. Make it work, then make it right.
WaitingToCreate -> {
if (elapsedTime >= 3.0) {
ship.position = Point(5000.0, 5000.0)
toBeCreated = listOf(ship)
HaveSeenShip
} else WaitingToCreate
}
Green? Green. Commit: After ship dies, it is reincarnated at universe center after 3 seconds.
I can’t resist playing the game and when I do I discover that the ship’s velocity has not been reset. So change the test and the code. (So tempting to change just the code, isn’t it? But the test serves to explain what is supposed to happen.)
assertThat(mon.state).describedAs("on time").isEqualTo(ShipMonitorState.HaveSeenShip)
assertThat(created).describedAs("on time").contains(ship)
assertThat(ship.position).isEqualTo(Point(5000.0, 5000.0))
assertThat(ship.velocity).isEqualTo(Velocity.ZERO)
WaitingToCreate -> {
if (elapsedTime >= 3.0) {
ship.position = Point(5000.0, 5000.0)
ship.velocity = Velocity.ZERO
toBeCreated = listOf(ship)
HaveSeenShip
} else WaitingToCreate
}
Test. Green. Commit: Ship respawns with velocity zero.
Let’s reflect.
Reflection
override fun update(deltaTime: Double): List<IFlyer> {
elapsedTime += deltaTime
var toBeCreated: List<IFlyer> = emptyList()
state = when (state) {
HaveSeenShip -> LookingForShip
LookingForShip -> {
elapsedTime = 0.0
WaitingToCreate
}
WaitingToCreate -> {
if (elapsedTime >= 3.0) {
ship.position = Point(5000.0, 5000.0)
ship.velocity = Velocity.ZERO
toBeCreated = listOf(ship)
HaveSeenShip
} else WaitingToCreate
}
}
return toBeCreated
}
This code is rather more open than one might like. We could extract the ship stuff but it’s just a couple of lines. We could extract the whole WaitingToCreate block. Let’s try that just to see if we like it. No, we can’t because it needs to have two returns, the new state and the update to toBeCreated, which it couldn’t see from inside. We could do the inside bit:
override fun update(deltaTime: Double): List<IFlyer> {
elapsedTime += deltaTime
var toBeCreated: List<IFlyer> = emptyList()
state = when (state) {
HaveSeenShip -> LookingForShip
LookingForShip -> {
elapsedTime = 0.0
WaitingToCreate
}
WaitingToCreate -> {
if (elapsedTime >= 3.0) {
toBeCreated = listOf(shipReset())
HaveSeenShip
} else WaitingToCreate
}
}
return toBeCreated
}
private fun shipReset(): IFlyer {
ship.position = Point(5000.0, 5000.0)
ship.velocity = Velocity.ZERO
return ship
}
That’s arguably better. Green. Commit: Refactor update
with separate shipReset
function.
We commented out a test, what about that? It’s a huge long test that goes through a million steps to test: test ship monitor correctly adds a new ship
What we know from the test we just wrote is that once there is no ship, things proceed as they should. Would it suffice to test that the state changes correctly depending on whether there is or isn’t a ship?
That’s this code:
override fun interactWith(other: IFlyer): List<IFlyer> {
if (state == LookingForShip) {
if (other == ship)
state = HaveSeenShip
}
return emptyList() // no damage done here
}
We can test that directly. Let’s do.
@Test
fun `correctly detect ship`() {
val sixtieth = 1.0/60.0
val ship = Flyer.ship(Point(10.0, 10.0))
val mon = ShipMonitor(ship)
mon.update(sixtieth)
assertThat(mon.state).isEqualTo(ShipMonitorState.LookingForShip)
mon.interactWith(ship)
assertThat(mon.state).isEqualTo(ShipMonitorState.HaveSeenShip)
}
I think that should run. It does. I think these simple tests tell us what we want to know.
In deleting the commented-out test, I found two others that also check collision logic, so I think we have belt and suspenders here. Green. Commit: remove redundant tests.
What else?
We still have the safety check to do, we could look at the dropIn animation, and there’s the whole issue of how many times the ship can respawn, but it is Sunday, the clocks have fallen back and if I go sit on the kitchen table like the cat does, perhaps there will be bacon.
Summary
The delay before respawn went in quite nicely, which I take as a sign that the design is holding up. There was one outright mistake, namely that I didn’t set the ship velocity to zero. Upon observing the issue, the additional test and change was easy enough.
I have a vague feeling that the state machine logic could be better. The issue is that we have two things happening on a state change, production of the new state, which is sometimes conditional, and any side action to be taken on the transition, i.e. the ship spawning. Closer adherence to “how state machines should be done” might help us, but the code is so simple now that I’m not inclined to do something more complicated in hopes of making it somehow simpler.
We may revisit this area again when we deal with how many ships you can have, including winning free ones if your score is high enough.
Relatedly, in a sense: when you kill all the asteroids, you are supposed to get a new wave, with more of them than last time. How will we determine that there are no asteroids? Right now, you can’t ask an object whether it is an asteroid. This may be interesting … but fortunately it’s not for today.
For today: a nice new feature. Next time … we’ll do something. Come visit to find out what that something is.