Kotlin 189: Another Way
Let’s try another way of decelerating to see if we like it better.
Ship movement includes continuous deceleration when not accelerating, done like this:
fun accelerateToNewSpeedInOneSecond(vNew:Velocity, vCurrent: Velocity): Velocity {
// vNew = vCurrent + a*t
// t = 1
// a = vNew - vCurrent
return vNew - vCurrent
}
private fun move(deltaTime: Double) {
position = (position + velocity * deltaTime).cap()
if (! accelerating ) {
val acceleration = accelerateToNewSpeedInOneSecond(velocity*U.SHIP_DECELERATION_FACTOR, velocity)*deltaTime
velocity += acceleration
}
}
This code cuts the ship’s speed in half every second, resulting in rapid deceleration from high speed and a long glide-out, incrementally slower and slower. It’s not a bad effect, but I’d like to try a constant deceleration rate to see if it’s better.
I’ll just type in the code I’ve imagined and we’ll see what happens.
private fun move(deltaTime: Double) {
val decel = 90.0 // to be moved to U
position = (position + velocity * deltaTime).cap()
if (! accelerating ) {
val speed = velocity.length
if (speed > 0 ) {
if (speed > decel*deltaTime) {
velocity -= velocity.normalized*decel*deltaTime
} else velocity = Velocity.ZERO
}
}
}
This gives a pretty decent effect. Deceleration is slower at 90 than acceleration (120). It feels pretty natural but after trying it both ways, I prefer what we had. So much for midnight ideas, I guess.
Roll back.
Reflection
Was this worth trying? I think so. It could have felt better in play, but it didn’t. There’s no reason at all why the ship would decelerate in space, it’s just a characteristic of the game to make it a better game. This new way could have been an improvement, and took just a few minutes to find out. Worth a try.
Now What?
I find the saucer to be a problem. In the original game, the saucer could only have one bullet on the screen at a time. In this version, it fires every half second, and a missile lasts three seconds, so there are half a dozen traces on the screen. Since my missiles splat when they die out, there are even more traces than that.
The saucer breaks up lots of asteroids, and it has too good a chance of hitting the ship even by accident. Let’s see if we can figure a way to limit it to one missile at a time.
Every half second, the saucer runs this code:
fun fire(trans: Transaction) {
if ( sawShip ) {
if (Random.nextInt(4) == 0 ) fireTargeted(trans)
else fireRandom(trans)
}
}
private fun fireRandom(trans: Transaction) {
timeSinceLastMissileFired = 0.0
trans.add(Missile(this))
}
private fun fireTargeted(trans: Transaction) {
timeSinceLastMissileFired = 0.0
val targetPosition = getTargetPosition()
val directionToShip = (targetPosition - position)
val heading = atan2(y = directionToShip.y, x = directionToShip.x).asDegrees
val missile = Missile(position, heading, killRadius, Velocity.ZERO, ColorRGBa.RED)
trans.add(missile)
}
Saucer already interacts with missiles:
interactWithMissile = { missile, trans -> checkCollision(missile, trans) },
If we were to record the missile we fire, we could check in interactWithMissile
to see if it is gone and if it is, allow firing again.
Let me write a test for that.
@Test
fun `saucer will not fire when its missile still lives`() {
val saucer = Saucer()
val trans = Transaction()
saucer.sawShip = true
saucer.fire(trans)
val missile: Missile = trans.firstAdd() as Missile
saucer.subscriptions.beforeInteractions()
saucer.subscriptions.interactWithMissile(missile, trans)
saucer.sawShip = true
val empty = Transaction()
saucer.fire(empty)
assertThat(empty.adds.size).isEqualTo(0)
}
This is a bit more intricate than I might like, but it should fail, and it does:
expected: 0
but was: 1
Now to implement the feature. Let’s have a flag missileReady
. We set it to true in before
and to false if we see our own missile. And we’ll need to record whatever missile we fire. I’ll at least begin by allowing that variable to be null.
class Saucer ...
var missileReady = true
var previousMissile: Missile? = null
beforeInteractions = { sawShip = false; missileReady = true },
interactWithMissile = { missile, trans ->
if (missile == previousMissile ) missileReady = false
checkCollision(missile, trans) },
private fun fireRandom(trans: Transaction) {
timeSinceLastMissileFired = 0.0
val missile = Missile(this)
previousMissile = missile
trans.add(missile)
}
private fun fireTargeted(trans: Transaction) {
timeSinceLastMissileFired = 0.0
val targetPosition = getTargetPosition()
val directionToShip = (targetPosition - position)
val heading = atan2(y = directionToShip.y, x = directionToShip.x).asDegrees
val missile = Missile(position, heading, killRadius, Velocity.ZERO, ColorRGBa.RED)
previousMissile = missile
trans.add(missile)
}
The rigmarole with missile
and previousMissile
is because previousMissile
is nullable and Kotlin thinks it could have changed back to null. I’ll want to improve this, I think. Let’s make it work, then make it right. I think the test should now run. It does not: it still fires. Oh, might be good to check the flag. Duh.
fun fire(trans: Transaction) {
if ( sawShip && missileReady ) {
if (Random.nextInt(4) == 0 ) fireTargeted(trans)
else fireRandom(trans)
}
}
Now it better run. It does. I run the game to see how it feels. I do like it better, and it’s certainly more authentic this way. I need a break. We’re green. Commit: Saucer can only have one missile at a time on screen.
Later that day …
That code isn’t what it could be. The biggest issue is that the saving of the current missile is done in two places. And maybe it should be called current missile, not previous?
We have this:
private fun fireRandom(trans: Transaction) {
timeSinceLastMissileFired = 0.0
val missile = Missile(this)
previousMissile = missile
trans.add(missile)
}
private fun fireTargeted(trans: Transaction) {
timeSinceLastMissileFired = 0.0
val targetPosition = getTargetPosition()
val directionToShip = (targetPosition - position)
val heading = atan2(y = directionToShip.y, x = directionToShip.x).asDegrees
val missile = Missile(position, heading, killRadius, Velocity.ZERO, ColorRGBa.RED)
previousMissile = missile
trans.add(missile)
}
Extract method for those last two lines, and we get this:
private fun fireRandom(trans: Transaction) {
timeSinceLastMissileFired = 0.0
val missile = Missile(this)
fireMissile(missile, trans)
}
private fun fireTargeted(trans: Transaction) {
timeSinceLastMissileFired = 0.0
val targetPosition = getTargetPosition()
val directionToShip = (targetPosition - position)
val heading = atan2(y = directionToShip.y, x = directionToShip.x).asDegrees
val missile = Missile(position, heading, killRadius, Velocity.ZERO, ColorRGBa.RED)
fireMissile(missile, trans)
}
private fun fireMissile(missile: Missile, trans: Transaction) {
previousMissile = missile
trans.add(missile)
}
IDEA is so nice about things like that. Now the setting is just done in one spot. Fewer opportunities to get it wrong. Let’s rename:
private fun fireMissile(missile: Missile, trans: Transaction) {
currentMissile = missile
trans.add(missile)
}
Oh, we should be testing and committing. Small steps. Commit: refactor missile firing to save missile in only one place. rename to currentMissile.
This refactoring has removed the awkwardness of the currentMissile being nullable. We can inline the creation of the easy one:
private fun fireRandom(trans: Transaction) {
timeSinceLastMissileFired = 0.0
val missile = Missile(this)
fireMissile(missile, trans)
}
private fun fireTargeted(trans: Transaction) {
timeSinceLastMissileFired = 0.0
val targetPosition = getTargetPosition()
val directionToShip = (targetPosition - position)
val heading = atan2(y = directionToShip.y, x = directionToShip.x).asDegrees
val missile = Missile(position, heading, killRadius, Velocity.ZERO, ColorRGBa.RED)
fireMissile(missile, trans)
}
I plan to inline just the first one, but I’m showing them both because I note the duplication of the time setting. That can be moved up. First the inline:
private fun fireRandom(trans: Transaction) {
timeSinceLastMissileFired = 0.0
fireMissile(Missile(this), trans)
}
Test. Green. Commit: inline call to Missile creation.
Now move that time setting up to the callers.
fun fire(trans: Transaction) {
if ( sawShip && missileReady ) {
timeSinceLastMissileFired = 0.0
if (Random.nextInt(4) == 0 ) fireTargeted(trans)
else fireRandom(trans)
}
}
Test. Green. Commit: Refactor to remove duplicate setting of timeSinceLastMissileFired.
private fun fireTargeted(trans: Transaction) {
val targetPosition = getTargetPosition()
val directionToShip = (targetPosition - position)
val heading = atan2(y = directionToShip.y, x = directionToShip.x).asDegrees
val missile = Missile(position, heading, killRadius, Velocity.ZERO, ColorRGBa.RED)
fireMissile(missile, trans)
}
I think I’ll try inlining targetPosition
here. It’ll be a bit less complicated and I think no less clear.
private fun fireTargeted(trans: Transaction) {
val directionToShip = (getTargetPosition() - position)
val heading = atan2(y = directionToShip.y, x = directionToShip.x).asDegrees
val missile = Missile(position, heading, killRadius, Velocity.ZERO, ColorRGBa.RED)
fireMissile(missile, trans)
}
I could wish that atan2
had a version that takes a vector. Failing that let’s do this extract:
private fun fireTargeted(trans: Transaction) {
val heading = headingToShip()
val missile = Missile(position, heading, killRadius, Velocity.ZERO, ColorRGBa.RED)
fireMissile(missile, trans)
}
private fun headingToShip(): Double {
val directionToShip = (getTargetPosition() - position)
return atan2(y = directionToShip.y, x = directionToShip.x).asDegrees
}
And now, let’s inline the heading temp:
private fun fireTargeted(trans: Transaction) {
val missile = Missile(position, headingToShip(), killRadius, Velocity.ZERO, ColorRGBa.RED)
fireMissile(missile, trans)
}
I think that’s much nicer. Test. Green. Commit: refactor fireTargeted for clarity.
Here’s a look at the firing logic as a whole:
fun fire(trans: Transaction) {
if ( sawShip && missileReady ) {
timeSinceLastMissileFired = 0.0
if (Random.nextInt(4) == 0 ) fireTargeted(trans)
else fireRandom(trans)
}
}
private fun fireRandom(trans: Transaction) {
fireMissile(Missile(this), trans)
}
private fun fireTargeted(trans: Transaction) {
val missile = Missile(position, headingToShip(), killRadius, Velocity.ZERO, ColorRGBa.RED)
fireMissile(missile, trans)
}
private fun headingToShip(): Double {
val directionToShip = (getTargetPosition() - position)
return atan2(y = directionToShip.y, x = directionToShip.x).asDegrees
}
private fun fireMissile(missile: Missile, trans: Transaction) {
currentMissile = missile
trans.add(missile)
}
fun getTargetPosition() = ShotOptimizer.optimizeShot(position, shipFuturePosition)
And, just in case you’re curious, here’s ShotOptimizer:
object ShotOptimizer {
fun preferred(shooter: Double, target: Double): Double {
val lowerTarget = target - 1024
val higherTarget = target + 1024
val actualDistance = abs(target - shooter)
val lowerDistance = abs(lowerTarget - shooter)
val higherDistance = abs(higherTarget - shooter)
return when {
lowerDistance < actualDistance -> lowerTarget
higherDistance < actualDistance -> higherTarget
else -> target
}
}
fun optimizeShot(shooter: Point, target: Point): Point {
return Point(preferred(shooter.x, target.x), preferred(shooter.y, target.y))
}
}
That’s the code that decides whether to shoot across the borders.
I think we’re good. Let’s sum up, concluding with a look at our list of things that may need doing.
Summary
We started with an experiment to try a different style of “friction” deceleration. In my judgment, the original way looked better on screen. I suppose that I could have skipped the exercise, since the original looked good enough, but I wanted to try the new form because it brought the ship to a complete halt, and because applying a constant value of deceleration rather than the continually declining one that was used (and still is). If the change had been going to take a day, I’d not have done it, but for the few minutes of experimentation and learning, I thought it worthwhile, and still do.
Then we (OK, I) decided to emulate the original game and make the Saucer fire only one bullet at a time. It makes the game more like the original, and more “fair”, by which I mean it gives me a better chance to make a few points. Irritatingly, in the test game I just ran, the EXPLETIVE DELETED saucer hit me with an unaimed shot. I am really not good at this game.
In the original game, the Saucer’s missile is in a known location, and the code can check to see if it is active or not. It would be possible to do that, giving our Saucer a single missile member variable and reusing it, but I think it’s more in the spirit of this design to keep making new ones, and simply to record the most recent one made. Then we used the standard interaction logic to check whether the current known missile is still active, and if not, we’re free to fire another.
It’s certainly more intricate than the original, but it does fit with the overall design, where our objects don’t know each other, or is there a central all-knowing place. Instead, objects find things out dynamically. Is this a better design than one with more centralized knowledge? I’d hesitate to say that it is, but it is interesting, and that’s why I did it this way.
I would argue, gently, that this scheme is a useful one to know, with its overall operational pattern:
- Make an assumption about the situation.
- Observe the situation through interactions, possibly changing the assumption.
- Act on the results.
I remain interested in coding up the game with a more conventional centralized kind of control, just to get a sense of the difference. That said, if we were dealing with more “AI” in our objects, smarter saucers or whatever, I think this more autonomous style would be quite a decent way to go.
We wound up with some refactoring of the firing code, resulting in a handful of nice, short methods that make sense (at least to me). That’s the style that I prefer and I’m pleased with how it went.
I continue to enjoy working with Kotlin and IDEA, they’re very nice tools.
Let’s review the list. I’ve reordered it a bit and changed a couple of priorities. I note that today we didn’t work from the list. That’s the trouble with Jira, it doesn’t reflect what you really want.
Priority = H/M/L; Done = √; Internal Improvement = (i)
- H Sound???
- H Small saucer (1000 points) appears after 30,000 points.
- M Change DecelerationTest to use the function that Ship uses.
- M Ability to change some settings from keyboard (cheat codes)
- M Move the Ship deceleration function to Universe?
- M Add a sun with gravity?
- √ (i, more to do) Improve generality of graphics, stroke width vs scale etc.
- √ (i, more to do) Eliminate magic numbers, moving to Universe.
- M (i) Timing code could be improved to be more consistent. OneShot?
- M Let Saucer fire in attract mode even with no ship present. (New.)
- L Some write-ups say that small asteroids are faster. (Needs research)
- L Saucer zig-zagging could be a bit more random.
- L Allow for non-square windows?
-
L (changed) Small saucer shot accuracy improves. (As good as original.)
- √ Saucer fires only one missile at a time. (New.)
- √ Ship should slow à la friction.
- √ Ship exhaust flare
- √ Ship turning seems a bit slow. 180->200. Accuracy seems fine.
- √ Ship acceleration seems sluggish. (1000 -> 1200)
- √ Hyperspace return away from edges for visibility.
- √ Check general scale of ship etc against original.
- √ (i, more to do) Improve generality of graphics, stroke width vs scale etc.
- √ (i, more to do) Eliminate magic numbers, moving to Universe.
- √ Saucer does not fire when ship is absent
We’ll pick some more things next time. Maybe even from the list. See you then!