Kotlin 207 - Collisions
GitHub Decentralized Repo
GitHub Centralized Repo
Let’s experiment today with the sketched collision logic that I threw in a number of days ago. Maybe it has been long enough to see its flaws. Hypotenuse!
Back in December, in the old decentralized version, I sketched an interaction function, just to get a sense of how it might be done in a centralized form. It’s still in the decentralized code, unused, and it’s in the centralized repo as well. It goes like this:
// spike sketch of centralized interaction
fun interact(attacker: SpaceObject, target: SpaceObject, trans: Transaction) {
if (attacker == target) return
if (outOfRange(attacker, target)) return
if (attacker is Missile) {
if (target is Ship) {
remove(attacker)
explode(target)
} else if (target is Saucer) {
remove(attacker)
explode(target)
} else if (target is Asteroid) {
remove(attacker)
if (attacker.missileIsFromShip) addScore(target)
split(target)
}
} else if (attacker is Ship) {
} else { // attacker is Saucer
}
}
private fun outOfRange(a: SpaceObject, b: SpaceObject) = false
private fun remove(o: SpaceObject) {}
private fun explode(o: SpaceObject) {}
private fun split(o: SpaceObject) {}
private fun addScore(o: SpaceObject) {}
Frequent visitors may recall that the flying space objects, missile, ship, saucer, and asteroids are saved in two collections, attackers (missiles, ship, saucer) and targets (ship, saucer, and asteroids). Any object from the attackers can destroy any object from targets other than itself, if they are colliding. I hope that the code is pretty clear. Let’s finish the sketch and see what we think.
Written out longhand, I come up with this, which looks right but might not be:
// spike sketch of centralized interaction
fun interact(attacker: SpaceObject, target: SpaceObject, trans: Transaction) {
if (attacker == target) return
if (outOfRange(attacker, target)) return
if (attacker is Missile) {
if (target is Ship) {
remove(attacker)
explode(target)
} else if (target is Saucer) {
remove(attacker)
explode(target)
} else if (target is Asteroid) {
remove(attacker)
if (attacker.missileIsFromShip) addScore(target)
split(target)
}
} else if (attacker is Ship) {
if (target is Saucer) {
explode(attacker)
remove(attacker)
addScore(target)
remove(target)
} else if (target is Asteroid) {
explode(attacker)
remove(attacker)
addScore(target)
split(target)
}
} else { // attacker is Saucer
if ( target is Ship ) {} // already done
else { // target is Asteroid
explode(attacker)
remove(attacker)
split(target)
}
}
}
We can see opportunities for improvement. Ideas include:
- When two things collide, they are both removed, although asteroids sometimes split rather than just disappear.
- If we implemented
explode
on everything, it could contain the remove and split as needed. - Some actions could be moved upward in the function, reducing duplication. (Perhaps at the cost of clarity?)
- Judicious use of
as
might let us write more specific functions on the various classes. - There is nothing in this function that makes it seem a suitable part of Game class. Maybe it should be in its own class.
And a big area for improvement:
- Comprehensive tests for this function would be a help both in determining that it works, and in writing it, since the results would all be tabulated.
What I do not see clearly is a good way to test it. I think we can assume that we’ll be using the Transaction to record all the things that need to be done. Maybe we can devise some useful way to inspect a Transaction for correctness.
Idea
I think it would be wise to start writing some tests for this interact
function and see what they could be made to look like. And I think we’ll rather quickly move this code to its own class. Let’s find out.
I’ll make a new test class for this: InteractionTests. I write this first test, which is a bit more than I really need for a first test, but I wanted to get a sense of how they might be written.
@Test
fun `can do an Interaction`() {
val ship = Ship(Point(100.0,100.0))
val int = Interaction()
val trans = Transaction()
int.interact(ship, ship, trans)
assertThat(trans).isEmpty()
}
This assumes a class Interaction with a method interact
, accepting two space objects and a transaction. Let’s move all that stuff from game into a new Interaction class. First let IDEA make the class.
package com.ronjeffries.ship
class Interaction {
}
Now IDEA would like to create a method, but I think I’ll cut all that stuff from Game and just paste it over. Will IDEA move it for me? If it can, I don’t see how, so I just cut and paste. The test now wants isEmpty
on Transaction, which doesn’t surprise me because I just invented the idea. Let’s implement it.
Ewww … that wants to do a test extension function. I’d like to know how to do that, but not right now. Change the test:
@Test
fun `can do an Interaction`() {
val ship = Ship(Point(100.0,100.0))
val int = Interaction()
val trans = Transaction()
int.interact(ship, ship, trans)
assertThat(trans.isEmpty()).isEqualTo(true)
}
Now that goes on Transaction. I decide to make it an extension and keep it here in the test for a while. That will keep us from glomming up Transaction with more test-related functions.
fun Transaction.isEmpty(): Boolean {
if (adds.isNotEmpty()) return false
if (removes.isNotEmpty()) return false
if (shouldClear) return false
if ( score > 0 ) return false
return true
}
Our test compiles now, and I rather think it should run.
Yikes. A test broke and when I reran them to bring it to the top, we were green. We may have another intermittent test. We’ll find out.
Anyway, this first test for Interaction runs green. Let’s just keep writing tests and making them work for a while. It has to lead somewhere interesting.
@Test
fun `missile does not kill ship if out of range`() {
}
I decide on this one, because I think I can build it up incrementally. I’ll create the missile and ship out of range of each other, then incrementally build the checking up. I’m assuming that I’ll make them destroy each other unconditionally and then add in the range. I could be wrong, but that’s why I chose the negative test first.
@Test
fun `missile does not kill ship if out of range`() {
val ship = Ship(Point(100.0,100.0))
val missile = Missile(Point(900.0,900.0))
val int = Interaction()
val trans = Transaction()
int.interact(missile, ship, trans)
assertThat(trans.isEmpty()).isEqualTo(true)
}
This much may run. Honestly I don’t know what it’ll do. Yes, it runs. Let’s look at the interaction code and enhance it to make it think they should destruct. I’ll have to start filling in those empty helper functions.
I change this:
private fun outOfRange(a: SpaceObject, b: SpaceObject) = true
That should give me a fail. Well, not yet, because the other functions don’t do anything. I don’t think we’re even accepting a transaction yet. Let me do a bit more work.
(This is odd because I have such a comprehensive sketch. Normally one might be building this thing up more from scratch. So it’s odd. We’ll cope.)
I merge the use of the input transaction down into remove and explode, and implement those two functions:
class Interaction {
// spike sketch of centralized interaction
fun interact(attacker: SpaceObject, target: SpaceObject, trans: Transaction) {
if (attacker == target) return
if (outOfRange(attacker, target)) return
if (attacker is Missile) {
if (target is Ship) {
remove(attacker, trans)
explode(target, trans)
} else if (target is Saucer) {
remove(attacker, trans)
explode(target, trans)
} else if (target is Asteroid) {
remove(attacker, trans)
if (attacker.missileIsFromShip) addScore(target)
split(target)
}
} else if (attacker is Ship) {
if (target is Saucer) {
explode(attacker, trans)
remove(attacker, trans)
addScore(target)
remove(target, trans)
} else if (target is Asteroid) {
explode(attacker, trans)
remove(attacker, trans)
addScore(target)
split(target)
}
} else { // attacker is Saucer
if ( target is Ship ) {} // already done
else { // target is Asteroid
explode(attacker, trans)
remove(attacker, trans)
split(target)
}
}
}
private fun outOfRange(a: SpaceObject, b: SpaceObject) = true
private fun remove(o: SpaceObject, trans: Transaction) {
trans.remove(o)
}
private fun explode(o: SpaceObject, trans: Transaction) {
remove(o, trans)
}
private fun split(o: SpaceObject) {}
private fun addScore(o: SpaceObject) {}
}
I expect the test to fail now on the isEmpty. Curiously enough, it doesn’t. Oh, duh, I should have left outOfRange false. Here we go:
expected: true
but was: false
I can see that I want more refinement in my checks. Let’s use this occasion to build outOfRange
, however.
We have a convenient helper object:
class Collision(private val collider: Collider) {
fun hit(other: Collider): Boolean
= collider.position.distanceTo(other.position) < collider.killRadius + other.killRadius
}
We use it this way:
private fun outOfRange(a: SpaceObject, b: SpaceObject): Boolean {
return ! Collision(a as Collider).hit(b as Collider)
}
I had to cast the space objects to Collider, but they all do implement that interface. Is it possible that the whole Interaction thing should be based on Collider? Er, no, we probably need other properties. Anyway we can rejigger the hierarchy if we need to. I expect it to get much simpler when this all works.
Test should pass now, I think. And it does.
Now let’s write the one that does collide:
@Test
fun `missile does kill ship if in range`() {
val ship = Ship(Point(100.0,100.0))
val missile = Missile(Point(110.0,110.0))
val int = Interaction()
val trans = Transaction()
int.interact(missile, ship, trans)
assertThat(trans.isEmpty()).isEqualTo(false)
}
I expect this to be green. But it’s red. Why? It’s recorded as out of range. I need more info.
I add a print and get this odd result:
out of range Missile Vector2(x=136.0, y=110.0) (1.0), Ship Vector2(x=100.0, y=100.0) (12.0)
How did the Missile get to 136? Ah. Missiles aren’t ready to be created exactly where we want them, they always get an offset. Let’s give ourselves a helper function in the test.
@Test
fun `missile does kill ship if in range`() {
val ship = Ship(Point(100.0,100.0))
val missile = missileAt(Point(110.0,110.0))
val int = Interaction()
val trans = Transaction()
int.interact(missile, ship, trans)
assertThat(trans.isEmpty()).isEqualTo(false)
}
}
fun missileAt(p: Point): Missile {
return Missile(p).also{ it. position = p}
}
Now can I get a green up in this baby? No! It still shows out of range but now the values are correct:
out of range Missile Vector2(x=110.0, y=110.0) (1.0), Ship Vector2(x=100.0, y=100.0) (12.0)
oh. hypotenuse. duh.
@Test
fun `missile does kill ship if in range`() {
val ship = Ship(Point(100.0,100.0))
val missile = missileAt(Point(110.0,100.0))
val int = Interaction()
val trans = Transaction()
int.interact(missile, ship, trans)
assertThat(trans.isEmpty()).isEqualTo(false)
}
Green. I’m chuckling at how confused I was for a moment. everyone knows that if you’re at 110,110 and I’m at 100,100, I’m about 14 away from you, not 13. Everyone knows that.
So we are green. Now let’s see about making better checks.
@Test
fun `missile does kill ship if in range`() {
val ship = Ship(Point(100.0,100.0))
val missile = missileAt(Point(110.0,100.0))
val int = Interaction()
val trans = Transaction()
int.interact(missile, ship, trans)
val checker = TransactionChecker(trans)
checker.removes(ship)
checker.removes(missile)
}
I’m going to have a new object, TransactionChecker, with handy methods. I’ll build it in place inside this test for now. Might move it later.
class TransactionChecker(private val trans: Transaction) {
fun removes(o:SpaceObject): Boolean = trans.removes.contains(o)
}
I expect this test to stay green. It does. But now we’ll make it harder …
@Test
fun `missile does kill ship if in range`() {
val ship = Ship(Point(100.0,100.0))
val missile = missileAt(Point(110.0,100.0))
val int = Interaction()
val trans = Transaction()
int.interact(missile, ship, trans)
val checker = TransactionChecker(trans)
checker.removes(ship)
checker.removes(missile)
val splats = checker.instances<Splat>()
assertThat(splats.size).isEqualTo(2)
}
I had a lot of help from IDEA and Kotlin on this one:
class TransactionChecker(val trans: Transaction) {
fun removes(o:SpaceObject): Boolean = trans.removes.contains(o)
inline fun <reified T> instances(): List<T> {
return trans.adds.filterIsInstance<T>()
}
}
I honestly have very little understanding of that new function. It has to be inline and it has to say reified
and it works. I’ll have to do some studying or ask my betters to explain it to me. And maybe there was a better way to do it, but I just kept trying red light bulbs until it compiled.
The test fails because we don’t explode.
In a reasonable language, I could just call Splat and let it sort out the class. Here I need to do something nasty. For now, I’ll let it be very nasty:
private fun explode(o: SpaceObject, trans: Transaction) {
trans.add(splatFor(o))
remove(o, trans)
}
private fun splatFor(o: SpaceObject): Splat {
return when (o) {
is Asteroid -> Splat(o)
is Missile -> Splat(o)
is Ship -> Splat(o)
is Saucer -> Splat(o)
else -> Splat(Point(-100.0, -100.0))
}
}
I’m sure there’s a better way but I think this will pass the test. Well, no, I only get one Splat. I think that may be OK … if a missile kills Ship or Saucer we may not want the small Splat. We don’t create one in the current version. Should we check that we have the right one?
assertThat(splats.size).isEqualTo(1)
val splat = splats.first()
assertThat(splat.scale).isEqualTo(2.0)
I expect green. I get it. Let’s commit: Initial InteractionTests and Interaction class.
I need a break and maybe it’s a good spot to stop. Let’s reflect and maybe sum up.
Reflection
Breaking out the Interaction class from Game seems to be a decent idea. It doesn’t use anything from Game and really only deals with pairs of SpaceObjects. If we had the separate interfaces, it could deal with attacker type and target type or even break them all the way down into the individual cases (but there are nine cases, so we probably won’t do that).
So far, I’m happy with the test helper, TransactionChecker. In fact, let’s move our Transaction extension in there:
class TransactionChecker(val trans: Transaction) {
fun removes(o:SpaceObject): Boolean = trans.removes.contains(o)
inline fun <reified T> instances(): List<T> {
return trans.adds.filterIsInstance<T>()
}
fun isEmpty(): Boolean {
if (trans.adds.isNotEmpty()) return false
if (trans.removes.isNotEmpty()) return false
if (trans.shouldClear) return false
if ( trans.score > 0 ) return false
return true
}
}
That’s better, I think.
What I have in mind for the Checker is that we might put some asserts into it directly.
Oh hell. I’m glad we started this. No one is checking the removes in this test:
@Test
fun `missile does kill ship if in range`() {
val ship = Ship(Point(100.0,100.0))
val missile = missileAt(Point(110.0,100.0))
val int = Interaction()
val trans = Transaction()
int.interact(missile, ship, trans)
val checker = TransactionChecker(trans)
checker.removes(ship)
checker.removes(missile)
val splats = checker.instances<Splat>()
assertThat(splats.size).isEqualTo(1)
val splat = splats.first()
assertThat(splat.scale).isEqualTo(2.0)
}
Here’s what I meant to do:
fun removes(o:SpaceObject) {
assertThat(trans.removes).contains(o)
}
Should still be green. Yes. To double check, I’ll put in this, to go red:
checker.removes(missileAt(Point(300.0, 300.0)))
This provides a red bar:
Expecting LinkedHashSet:
[Missile Vector2(x=110.0, y=100.0) (1.0), Ship Vector2(x=100.0, y=100.0) (12.0)]
to contain:
[Missile Vector2(x=300.0, y=300.0) (1.0)]
but could not find the following element(s):
[Missile Vector2(x=300.0, y=300.0) (1.0)]
Perfect. Remove that fake check. OK.
As I was saying, the Checker seems useful and should be made to do asserts where it’s helpful. We’ll surely do similar things for adds and so on. I expect to evolve it sort of like a tiny language.
I think we’ll wrap this up for the morning, so a quick Summary:
Summary
It seemed like a good time to begin working with the sketched interact function that I wrote back December 28th. And since it seems to be working and since the only thing that really surprised me was the square of the hypotenuse, it was a good time.
I don’t like the necessity to break out the classes for Splat creation. That could be done differently, and in fact if we created the Splats in line, we’d have the class present and could avoid the silly when
thing.
There will be duplication to remove, but that’s not a big issue. Even if we didn’t remove it, the object is straightforward and simple.
All in all, I think I like what’s happening. Your comments are always welcome. I’m ronjeffries at mastodon.social if you’re into that sort of thing.
I hope you’ll stop by next time!