Kotlin 162: Saucer
As much fun as I’m having with the odd simplicity / complexity mix of this program, it’s time for a feature. We’ll do the Saucer … or at least start on it.
The Saucer has perhaps the most complex behavior of anything in Asteroids. It appears “randomly”. It always starts at the right-left edge of the screen. It goes in the +x direction one time, -x the next. It flies at constant speed. Randomly, every second or so, it changes direction, choosing among straight, up 45, or down 45. About once a second, it fires a missile, usually in a random direction. Once in a while — I’ll have to look up the details — it fires a well-aimed shot that will almost certainly hit the Ship unless the Ship changes course. And … it comes in two sizes, with two associated scores if you shoot it down. Oh, and it can collide with asteroids, and it will be destroyed (for that cycle) if it does. I don’t recall whether you get the points for the asteroid it destroys: you’d think not.
We’ll build it up slowly, in small steps, but it seems wise to think about how it might be built. We know that it can receive the usual messages, update
, beginInteractions
, finishInteractions
, interactWith...
, finalize
, and draw
.
I suppose we’ll do the bulk of the work in update
, recalculating its velocity (which controls direction) once in a while, and moving position according to that value. It’ll need a time for direction changes, and I think that will have to be random, switching randomly in the interval 0.5 to 1.5 seconds. It will need another timer for firing its missiles, probably about once per second. And some means to decide when to fire a “good” shot. I’m not sure if that was entirely random in the original game or not. I’m pretty sure you’re guaranteed that the first shot in any crossing cycle will be random, not targeted. So there’ll be something there, a counter or random time or dice roll. I’ll try to find out what the original game did, or crib from my Lua version, which may be historically accurate. Maybe.
For consistency with the Ship and the asteroids, which have ShipChecker/SHipMaker, WaveChecker/WaveMaker, we might want to have a SaucerChecker and SaucerMaker. (And one of these days, we might want to look at those separate objects and see whether there are common characteristics that we should use to reduce the number of classes we have. I’m in love with these tiny objects, but I’m not a complete fanatic about it.)
<browsing> I see that my Lua version fires once per second. Its random turn time is, as I suspected, random between 0.5 and 1.5 seconds. The small saucer is chosen if the score is greater than 3000. That’ll be interesting: how will we find out the score?
My Lua saucer lives for seven seconds, unless it is killed before that. It appears that in my Lua game the saucer starts every seven seconds after the last time it met its unfortunate demise.
Let’s get started …
Let’s begin with a simple saucer that flies all the time, and work up to interesting behavior. We’ll try to test as much as we can. How about creating one and determining that it dies after seven seconds? Sounds like an easy start. No, I have an even easier one:
@Test
fun `created on edge`() {
val saucer = Saucer()
assertThat(saucer.position.x).isEqualTo(0)
}
IDEA will help. In practically no time we have the shell:
class Saucer(): ISpaceObject, InteractingSpaceObject {
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 did have to look up the interfaces that it has to implement. You don’t expect me to remember that sort of thing, do you?
I really think that the subscriptions and callOther should be at the top, don’t you? Probably I need to reverse the interface list to get IDEA to do it for me, but I’ll move them and start moving others when i notice. We don’t need messages or interactions yet, so:
class Saucer(): ISpaceObject, InteractingSpaceObject {
override val subscriptions = Subscriptions()
override fun callOther(other: InteractingSpaceObject, trans: Transaction) {
}
override fun update(deltaTime: Double, trans: Transaction) {
TODO("Not yet implemented")
}
override fun finalize(): List<ISpaceObject> {
TODO("Not yet implemented")
}
}
Now the test knows there is no position in Saucer, so:
class Saucer(): ISpaceObject, InteractingSpaceObject {
var position = Point(1.0,1.0)
I set it to 1,1 so that the test will fail, which it obligingly does. I’m going to create the init that I want: not sure of a good way to test a random setting. Along the way I rediscover this:
fun randomEdgePoint(): Point =
if (Random.nextBoolean()) Point(0.0, Random.nextDouble(UNIVERSE_SIZE))
else Point(Random.nextDouble(UNIVERSE_SIZE), 0.0)
Nearly useful. Should we do a randomSidePoint function? Not for now, we only need it here.
var position = Point(0.0, Random.nextDouble(U.UNIVERSE_SIZE))
This should pass our simple test. Well, it would if Kotlin understood that 0 equals 0.0. Fix the test to use 0.0.
Time to set velocity. Considering moving in the +x direction, there are three possibilities. If your speed were 1.0, then your possible velocities are horizontal (1.0, 0.0), upward (0.7071, 0.7071), and downward (0.7071, -0.7071)1. And same in the -x direction, except with negative x.
Now we don’t really care about the difference between up and down, since it’s random anyway, so when we select a random direction, select one of those three, and then multiply by our direction, which will be 1 or -1 alternating.
I should mention that I’m planning to use the same saucer over and over, as we do with the ship, because that makes it easier for the saucer to have behavior that varies over the long term, such as changing between left to right and right to left motion. We’ll have our SaucerMaker send a message to the Saucer telling it that it is being brought to life.
Let’s write a test or two:
@Test
fun `alternates left-right`() {
val saucer = Saucer()
saucer.wakeUp()
assertThat(saucer.velocity.x).isGreaterThan(0.0)
assertThat(saucer.velocity.y).isEqualTo(0.0)
saucer.changeDirection()
assertThat(saucer.velocity.x).isGreaterThan(0.0)
assertThat(saucer.velocity.y).isGreaterThan(0.0)
}
I wrote down more than I needed here, because I wanted to think through how it would work. I was thinking that we’d force an actual change on each changeDirection
call. Belay that idea. I’ll delete those last three lines and make the first bit work.
class Saucer(): ISpaceObject, InteractingSpaceObject {
var position = Point(0.0, Random.nextDouble(U.UNIVERSE_SIZE))
var direction = -1.0 // right to left, will invert on `wakeUp`
var velocity = Velocity.ZERO
fun wakeUp() {
direction = -direction
velocity = Velocity(direction,0.0)
}
This should pass. It does. Commit: Saucer passes first two tests. There are two TODO in there but we’re not creating any saucers, so it’s OK for now …
Another test and now I have an idea how to avoid the random issues: We’ll have a newDirection
method take an integer parameter and return a fixed result:
@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))
}
I’m committing to putting the random number somewhere else … and have an idea just where it might go. Wait and see.
Make this work:
val directions = listOf(Velocity(1.0,0.0), Velocity(0.7071,0.7071), Velocity(0.7071, -0.7071))
fun changeDirection(direction: Int): Velocity {
return when (direction) {
0,1,2 -> directions[direction]
else -> directions[0]
}
}
OK, that’s fine. I think we should get ready to make this thing fly.
We’ll need update and draw. We’ll draw a circle for now.
fun draw(drawer: Drawer) {
fun draw(drawer: Drawer) {
drawer.translate(position)
drawer.stroke = ColorRGBa.GREEN
drawer.fill = ColorRGBa.GREEN
drawer.circle(Point.ZERO, killRadius*3.0)
}
}
I have to subscribe to draw
:
override val subscriptions = Subscriptions(
draw = this::draw
)
And we need update. I’ll just move position according to velocity for now.
override fun update(deltaTime: Double, trans: Transaction) {
position = (position + velocity*deltaTime.cap())
}
Gotta run it, see what happens. We need to create a Saucer. I’ll just do that directly in the Game for now, to see it fly. To make its motion actually visible, it needs a speed value commensurate with the other objects. I wind up with this:
val speed = 1500.0
fun wakeUp() {
direction = -direction
velocity = Velocity(direction,0.0)*speed
}
Let’s hammer in a velocity change every second and a half.
override fun update(deltaTime: Double, trans: Transaction) {
elapsedTime += deltaTime
if ( elapsedTime > 1.5) {
elapsedTime = 0.0
setVelocity(newDirection())
}
position = (position + velocity*deltaTime).cap()
}
And … with some tweaking, I get something I can stand.
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()
}
I am bitten once again by the bizarre rule about setVelocity
becoming reserved if you have a variable named velocity
. Let’s get this code better organized before we move on.
fun wakeUp() {
direction = -direction
assignVelocity(Velocity(direction, 0.0))
}
fun assignVelocity(unitV: Velocity) {
velocity = unitV*speed
}
override fun update(deltaTime: Double, trans: Transaction) {
elapsedTime += deltaTime
if ( elapsedTime > 1.5) {
elapsedTime = 0.0
assignVelocity(newDirection(Random.nextInt(3)))
}
position = (position + velocity*deltaTime).cap()
}
This is a bit better: at least all the velocity setting is done in one place.
I am tempted to set only the direction, and to compute the velocity every time. Maybe later.
It looks like this:
I have the points that define the saucer, tucked away in my Lua program. Then I fiddle the scale to make the saucer about the same size as the ship:
Here’s the current draw
:
val points = listOf(
Point(-2.0, 1.0),
Point(2.0, 1.0),
Point(5.0, -1.0),
Point(-5.0, -1.0),
Point(-2.0, -3.0),
Point(2.0, -3.0),
Point(5.0, -1.0),
Point(2.0, 1.0),
Point(1.0, 3.0),
Point(-1.0, 3.0),
Point(-2.0, 1.0),
Point(-5.0, -1.0),
Point(-2.0, 1.0)
)
fun draw(drawer: Drawer) {
drawer.translate(position)
drawer.stroke = ColorRGBa.GREEN
drawer.fill = ColorRGBa.GREEN
val sc = 45.0
drawer.scale(sc, -sc)
drawer.strokeWeight = 8.0/sc
drawer.lineStrip(points)
}
The scale value of 45.0 is 1.5 times the ship’s scale, and makes the saucer about the same size as the ship. That’ll do for now.
Let’s do some colliding. Saucer will of course send interactWithSaucer
, and Ship and Asteroid need to deal with it.
class Saucer ...
override fun callOther(other: InteractingSpaceObject, trans: Transaction) {
other.subscriptions.interactWithSaucer(this, trans)
}
class Subscriptions(
val beforeInteractions: () -> Unit = {},
val interactWithMissile: (missile: Missile, trans: Transaction) -> Unit = { _, _, -> },
val interactWithSaucer: (saucer: Saucer, trans: Transaction) -> Unit = { _, _, -> },
...
class Asteroid
private fun weAreCollidingWith(saucer: Saucer): Boolean {
return position.distanceTo(saucer.position) < killRadius + saucer.killRadius
}
This much should allow the saucer to hit asteroids. It needs to respond as well:
override val subscriptions = Subscriptions(
draw = this::draw,
interactWithAsteroid = { asteroid, trans ->
if (weAreCollidingWith(asteroid) ) {
trans.remove(this)
}
}
)
private fun weAreCollidingWith(asteroid: Asteroid): Boolean {
return position.distanceTo(asteroid.position) < killRadius + asteroid.killRadius
}
I note that these tiny objects all implement one or more weAreCollidingWith
functions. It would be nice to consolidate those somehow. Making a note of it.
The current response of the Saucer to hitting an asteroid is to remove itself, which is pretty much what one does. But it will never come back. We’ll have to do SaucerChecker or SaucerMaker for that.
Instead, let’s continue with missiles and ship collisions.
override val subscriptions = Subscriptions(
draw = this::draw,
interactWithAsteroid = { asteroid, trans ->
if (weAreCollidingWith(asteroid) ) {
trans.remove(this)
}
},
interactWithShip = { ship, trans ->
if (weAreCollidingWith(ship) ) {
trans.remove(this)
}
},
interactWithMissile = { missile, trans ->
if (weAreCollidingWith(missile) ) {
trans.remove(this)
}
},
)
We’re definitely building up duplication here, but with this in place, and the corresponding weAreColliginWith
functions, the saucer now dies when I shoot it or crash into it.
Unfortunately, the corresponding methods need to be implemented on Missile and Ship. This is getting tedious and is showing a characteristic of the current design which is time-consuming and error-prone. If I were to “forget” to let the saucer kill the ship, I could ram him with impunity, but the game designers didn’t have that in mind.
A little live testing and I see that all the collisions work. I really need to implement all those combinations as tests, lest I make a mistake. But I am tired now, so let’s sum up.
Summary
The tiny object approach pays off in a simple straightforward way of building a new thing like a Saucer. It all went perfectly except for a brief period where I had mangled the draw function so that it didn’t draw. Each of these objects is set up the same … so similarly that I can really feel the duplication that is building up:
For each object that interacts with the others, we have to build an interaction subscription … and the code for that subscription needs to be aware of the class of the object, so we get duplication of weAreCollidingWith
. I notice that Ship says weAreInRange
. Let’s rename those to match the others. IDEA makes that easy, spotting all the usages.
We definitely need more tests, but the ones we have are green, and the game runs perfectly well, with the saucer appearing once … and usually quickly running into an asteroid and disappearing. Commit: initial saucer flies and dies.
We of course need a Saucer Check/Make setup. I’m starting to think that, with our current full panoply of events, we might be able to combine the checking and making function, at least for something as simple as the Saucer. The Ship’s emergence is more complicated, and even the asteroids have a bit more heft to them. Maybe doing this one as a single object will give us ideas for the others.
Bottom line, we have inserted the Saucer into the mix and so far it’s going quite smoothly. We are seeing more duplication than we’d like, and a corresponding amount of tedious insertion of new nearly duplicated logic into all the little objects with which a new object interacts. This is the first time I’ve started to feel any pain from the tiny object approach … though not the first time I’ve thought it might be coming.
- Last Minute Observation
- As things stand, you do get the points if the saucer collides with an asteroid. In my Lua version, you do not get the points. I’ll have to look into that and decide what to do about it.
I can’t wait to find out what I do next! Join me?
-
Proof left to the reader. ↩