Kotlin 224 - The New Old Collisions
GitHub Decentralized Repo
GitHub Centralized Repo
I think we’re ready to try the new simpler collision scheme, which is really the old collision scheme from before we started this centralized version. We’ll discuss further whether this is a step backward or forward.
Yesterday I added the various interact
definitions, and interactWith
to the Collider interface, after test-driving one matched pair, asteroid vs missile. We already have tests for all the interactions, so my new test really just drove out and tested the double dispatch, known.intaractWith(unknown)
to known.interact(known)
. Once we were sure that worked, adding the methods all to the interface caused Kotlin and IDEA to drag us through marking the existing methods override
and implementing a few new null ones. So I think we are ready to replace the code in Interaction.
Since our tests will fail if I get it wrong, I can just go ahead and replace what Interaction does. We use it this way:
class Game ...
fun changesDueToInteractions(): Transaction {
val trans = Transaction()
with (knownObjects) {
Interaction(missiles, ships, saucers, asteroids, trans)
}
return trans
}
Since Interaction expects all those specialized collections, I think we’ll do better not to work with it. Instead, I’ll begin by making the new scheme work from within game, and then we’ll see whether we want to change how things are arranged.
We just want to interact all pairs of colliders, both ways. Right now, I think the pair code deals with SpaceOject instances, and we just want colliders.
class SpaceObjectCollection ...
fun pairsToCheck(): List<Pair<SpaceObject, SpaceObject>> {
val spaceObjects = spaceObjects()
val pairs = mutableListOf<Pair<SpaceObject, SpaceObject>>()
spaceObjects.indices.forEach { i ->
spaceObjects.indices.minus(0..i).forEach { j ->
pairs.add(spaceObjects[i] to spaceObjects[j])
}
}
return pairs
}
We want that to produce pairs of Collider, so we’ll change the signature, and change the function to produce them.
fun pairsToCheck(): List<Pair<Collider, Collider>> {
val colliders = colliders()
val pairs = mutableListOf<Pair<Collider, Collider>>()
colliders.indices.forEach { i ->
colliders.indices.minus(0..i).forEach { j ->
pairs.add(colliders[i] to colliders[j])
}
}
return pairs
}
Now I need a function colliders
. We’ll make this efficient later, for now we are here to make it work. There may be a better way to do this but this should work:
private fun colliders(): List<Collider> {
val candidates = spaceObjects().filter { it is Collider }
return candidates.map { it as Collider}
}
Get all the colliders from spaceObjects() and map them as Collider for the return. Nasty but I think correct. Now let’s use the pairs thing:
fun changesDueToInteractions(): Transaction {
val trans = Transaction()
knownObjects.pairsToCheck().forEach {
it.first.interactWith(it.second, trans)
it.second.interactWith(it.first, trans)
}
return trans
}
I am hopefully optimistic that this will work. Test. We run green. Does the game work? Indeed it does. I can commit this, though it could use a little improvement, and I will: Game uses pairsToCheck
and interactWith
for collisions. Interaction unused.
We don’t really want to stop here. There’s a lot we will want to do before we’re done:
- Remove the Interaction class and its tests;
- Make access to our colliders collection more efficient.
- Remove the detailed classes, asteroids, missiles, and so on from SpaceObjectCollection
- Change SpaceObjectCollection to remove all the specialized methods
- Change Transaction similarly
We might find it necessary to provide helper methods to produce all the asteroids or other instances, to preserve useful tests.
Let’s get started. First let’s see about Interaction. All references to it are in InteractionTest. Safe Delete that and then Interaction. Quick and easy. Test. Green as grass. Commit: Remove Interaction and its tests.
I said above “make access to our colliders collection more efficient”. I don’t really care about efficiency per se, but it’s quite involved now, since it filters and maps spaceObjects() and that collection is virtual as well. Let’s take advantage of our specialized types in SpaceObjectCollection.
class SpaceObjectCollection
val colliders = mutableListOf<Collider>()
There’s an issue here. When we remove objects, we use this utility collection:
// update function below if you add to these
fun allCollections(): List<MutableList<out SpaceObject>> {
return listOf (asteroids, deferredActions, missiles, saucers, ships, splats)
}
fun remove(spaceObject: SpaceObject) {
for ( coll in allCollections()) {
coll.remove(spaceObject)
}
}
I can’t add colliders
to that list because it’s not a list of SpaceObject. So I’ll have to remove separately:
fun remove(spaceObject: SpaceObject) {
for ( coll in allCollections()) {
coll.remove(spaceObject)
}
if (spaceObject is Collider ) colliders.remove(spaceObject)
}
I don’t love the check, but I think I’m stuck with it. We’ll see what we can do when things next settle down.
Now I can add relevant objects to colliders
.
fun add(asteroid: Asteroid) {
asteroids.add(asteroid)
colliders.add(asteroid)
}
fun add(missile: Missile) {
missiles.add(missile)
colliders.add(missile)
}
fun add(saucer: Saucer) {
saucers.add(saucer)
colliders.add(saucer)
}
fun add(ship: Ship) {
ships.add(ship)
colliders.add(ship)
}
Now, unless I miss my guess, we can use the colliders
collection in pairsToCheck
:
fun pairsToCheck(): List<Pair<Collider, Collider>> {
val pairs = mutableListOf<Pair<Collider, Collider>>()
colliders.indices.forEach { i ->
colliders.indices.minus(0..i).forEach { j ->
pairs.add(colliders[i] to colliders[j])
}
}
return pairs
}
Test. Green. Test game too. Make note that I felt it necessary. Game’s good. Commit: SpaceObjectCollection maintains separate colliders
collection.
Now do we need all those separate collections now? I suspect not. Look for references. There must be some, because IDEA isn’t suggesting making the collections private.
Game wants to know:
fun canShipEmerge(): Boolean {
if (knownObjects.saucerIsPresent()) return false
if (knownObjects.missiles.size > 0) return false
for ( asteroid in knownObjects.asteroids ) {
val distance = asteroid.position.distanceTo(U.CENTER_OF_UNIVERSE)
if ( distance < U.SAFE_SHIP_DISTANCE ) return false
}
return true
}
Let’s provide a function.
fun asteroids() = spaceObjects().filterIsInstance<Asteroid>()
fun asteroidCount(): Int = asteroids().size
Now who’s looking at the member collection? One test, and now it can be marked private in SpaceObjectCollection.
I’ll do that but then I think I want to recreate the spaceObjects as a real collection in here.
private val spaceObjects = mutableListOf<SpaceObject>()
fun spaceObjects():List<SpaceObject> = spaceObjects
fun add(asteroid: Asteroid) {
asteroids.add(asteroid)
colliders.add(asteroid)
spaceObjects.add(asteroid)
}
fun add(missile: Missile) {
missiles.add(missile)
colliders.add(missile)
spaceObjects.add(missile)
}
fun add(saucer: Saucer) {
saucers.add(saucer)
colliders.add(saucer)
spaceObjects.add(saucer)
}
fun add(ship: Ship) {
ships.add(ship)
colliders.add(ship)
spaceObjects.add(ship)
}
fun add(splat: Splat) {
splats.add(splat)
spaceObjects.add(splat)
}
fun remove(spaceObject: SpaceObject) {
for ( coll in allCollections()) {
coll.remove(spaceObject)
}
spaceObjects.remove(spaceObject)
if (spaceObject is Collider ) colliders.remove(spaceObject)
}
That should work just fine. Test. One test fails: canClearCollection. That’s a bug and probably is in the game as well. Fix:
fun clear() {
scoreKeeper.clear()
for ( coll in allCollections()) {
coll.clear()
}
spaceObjects.clear()
colliders.clear()
}
Green. Commit: spaceObjects no longer virtual. fix problem in clear.
Can we remove asteroids collection now? Yes, just don’t add them in asteroids add
and remove from allCollections
:
// update function below if you add to these
fun allCollections(): List<MutableList<out SpaceObject>> {
return listOf (deferredActions, missiles, saucers, ships, splats)
}
fun add(asteroid: Asteroid) {
colliders.add(asteroid)
spaceObjects.add(asteroid)
}
Let’s just tick through the others. I’ll report if there’s anything interesting beyond making an access function and fixing up tests.
Commit: asteroids, missiles and saucers collections now virtual.
I should have committed after finishing each one. I’ll try to improve. Now ships.
Commit: ships collection now virtual.
Commit: splats collection now virtual.
Now the only real collections in SpaceObjectCollection are deferredObjects, spaceObjects, and Colliders. I’ll make a function for deferredActions, so it can be like the rest. A few changes to call it and
Commit: deferredActions is private with accessor function.
Now I can undo this:
class SpaceObjectCollection
// update function below if you add to these
fun allCollections(): List<MutableList<out SpaceObject>> {
return listOf (deferredActions)
}
fun clear() {
scoreKeeper.clear()
for ( coll in allCollections()) {
coll.clear()
}
spaceObjects.clear()
colliders.clear()
}
fun remove(spaceObject: SpaceObject) {
for ( coll in allCollections()) {
coll.remove(spaceObject)
}
spaceObjects.remove(spaceObject)
if (spaceObject is Collider ) colliders.remove(spaceObject)
}
fun remove(spaceObject: SpaceObject) {
for ( coll in allCollections()) {
coll.remove(spaceObject)
}
spaceObjects.remove(spaceObject)
if (spaceObject is Collider ) colliders.remove(spaceObject)
}
@Test
fun `clear clears all sub-collections`() {
val s = SpaceObjectCollection()
s.add(Missile(Ship(U.CENTER_OF_UNIVERSE)))
s.add(Asteroid(Point.ZERO))
val deferredAction = DeferredAction(3.0, Transaction()) {}
s.add(deferredAction)
s.clear()
for ( coll in s.allCollections()) {
assertThat(coll).isEmpty()
}
The test gets changed to test all the collections explicitly. That’s unfortunate but necessary.
fun `clear clears all sub-collections`() {
val s = SpaceObjectCollection()
s.add(Missile(Ship(U.CENTER_OF_UNIVERSE)))
s.add(Asteroid(Point.ZERO))
val deferredAction = DeferredAction(3.0, Transaction()) {}
s.add(deferredAction)
s.clear()
assertThat(s.spaceObjects()).isEmpty()
assertThat(s.deferredActions()).isEmpty()
assertThat(s.colliders()).isEmpty()
}
I need to make an accessor for colliders. Easily done, though I think there is a better way if only I could think of it.
fun clear() {
scoreKeeper.clear()
deferredActions.clear()
spaceObjects.clear()
colliders.clear()
}
fun remove(spaceObject: SpaceObject) {
deferredActions.clear()
spaceObjects.remove(spaceObject)
if (spaceObject is Collider ) colliders.remove(spaceObject)
}
Should be green. Game has a bizarre bug: missiles are not terminating. Oh, look at that last line above. I cleared them all. Bad Ron, no biscuit.
fun remove(spaceObject: SpaceObject) {
deferredActions.remove(spaceObject)
spaceObjects.remove(spaceObject)
if (spaceObject is Collider ) colliders.remove(spaceObject)
}
Glad I didn’t commit that and that for some reason I tested in Game. Interesting that no tests failed.
We’re green as fake gold, and game is good. Commit: refactor to remove allCollections
.
I kind of miss allCollections
, because we have those two references to all the collections, and if we change how many collections we have, those two methods will have to change and I’ve already proven that I can forget one of them.
I’m not sure at this moment what to do about that. Let’s look at our adds. They’re “interesting”:
fun add(asteroid: Asteroid) {
colliders.add(asteroid)
spaceObjects.add(asteroid)
}
fun add(missile: Missile) {
colliders.add(missile)
spaceObjects.add(missile)
}
fun add(saucer: Saucer) {
colliders.add(saucer)
spaceObjects.add(saucer)
}
fun add(ship: Ship) {
colliders.add(ship)
spaceObjects.add(ship)
}
fun add(splat: Splat) {
spaceObjects.add(splat)
}
We can, if we wish, consolidate these:
fun add (spaceObject: SpaceObject) {
spaceObjects.add(spaceObject)
if (spaceObject is Collider) colliders.add(spaceObject)
}
That should allow me to remove all the type-specific ones. We are green. Commit: remove type-specific adds.
This is nearly a good place to stop. One thing comes to mind:
fun tick(deltaTime: Double) {
val trans = Transaction()
knownObjects.deferredActions().forEach { it.update(deltaTime, trans)}
knownObjects.forEachInteracting { it.update(deltaTime, trans) }
knownObjects.applyChanges(trans)
}
We could recast that this way:
fun tick(deltaTime: Double) {
knownObjects.performWithTransaction {trans ->
knownObjects.deferredActions().forEach { it.update(deltaTime, trans) }
knownObjects.forEachInteracting { it.update(deltaTime, trans) }
}
}
Or even this way:
fun tick(deltaTime: Double) {
with (knownObjects) {
performWithTransaction { trans ->
deferredActions().forEach { it.update(deltaTime, trans) }
forEachInteracting { it.update(deltaTime, trans) }
}
}
}
What, exactly, is forEachInteracting
?
fun forEachInteracting(action: (SpaceObject)->Unit) =
spaceObjects().forEach(action)
Why don’t we do a new for
, like this:
fun forEach(action: (SpaceObject)->Unit) {
deferredActions().forEach(action)
spaceObjects().forEach(action)
}
And use that:
fun tick(deltaTime: Double) {
with (knownObjects) {
performWithTransaction { trans ->
forEach { it.update(deltaTime, trans) }
}
}
}
Test. Green as Poison Ivy. Commit: refactor Game tick with new method in SpaceObjectCollection.
This is a good stopping point, as we come up on 500 lines.
Summary and Reflection
SpaceObjectCollection is about 10 lines shorter than it was. We removed a number of short add methods, and we added a number of short accessors for auxiliary collections that the tests wanted. We could perhaps move those off into the test side of things as extensions, saving probably another ten lines or so. We added the pairsToCheck
function back into SpaceObjectCollection, for use in Interaction.
Removing Interaction saved us 60 lines of code and 90 lines of test. Interaction was clean but odd, since it made all kinds of type-dependent choices. Now we do interact more pairs of objects, but the decisions are made entirely by the objects. We didn’t really add any logic to the objects to make that happen: the Interaction just saved some unnecessary calls that we now allow to happen, such as asteroids interacting with asteroids. those methods just return, so the change is pretty harmless. We’re only dealing with possibly 30 objects and computers are fast these days.
All the collections for asteroids, missiles, saucers, ships, and splats are no longer physically present in SpaceObjectCollection, but are made available “virtually”, by filtering. Most of those are only used in tests, although the game does like to count the asteroids.
Haven’t we kind of regressed?
It is absolutely true that we have essentially backed out almost all of the type-specific sorting that we put in less than a week ago. Is that a regression, or forward progress? I think it’s chalked up to trying something and learning from it.
I’ve learned how to be very specific with types, when I want to be, and I’ve become more comfortable with how one can leverage the type checking of Kotlin, and languages like it, to write small, very specific code. That’s very valuable to me as a programmer relatively new to type-checked languages.
And, I’ve learned that it’s possible to go further than needed in order to get what we need. The real distinctions that needed to be made are pretty much limited to:
- All the objects in the system, including DeferredAction as well as the asteroids, missiles, saucers, ships, and splats;
- All the flying objects, asteroid, missile, saucer, ship, and splat;
- All the colliding objects, asteroid, missile, saucer, ship, and not splat.
We do isolate out the individual collections, including the game wanting to know how many asteroids there are, and tests wanting to know other object counts.
We could optimize.
It would be easy enough now to keep the asteroids separately for faster counting, and it wouldn’t be terribly difficult to keep a live count of asteroids rather than the collection. I would lean away from that because I’ve too often wound up with a counter that didn’t quite reflect reality. I’d prefer to trust that Kotlin knows how to get the size of a collection pretty quickly.
Are you sure all that type-specific code wasn’t a mistake?
If we look at the history of the code, yes, it looks like we put in a bunch of type-specific code and then removed it again. If we look at the history of our learning, the path moves a bit more in a positive direction, with increased understanding leading to better and better code.
Maybe I’m just rationalizing a mistake. I don’t think so, because I definitely understand how to leverage Kotlin’s type checking better than I did a week ago.
You probably already knew what I know now. That being the case, why didn’t you stop me before I did all this work?
Seriously, though, it was a few hours one way, a few the other way. In the grand scheme of things, it wasn’t much and while it may seem that there was a shorter path to where we are now, and a shorter path to where we’ll be in a day or so, that path was not available to us. Our brain (my brain) did not have a direct path from where we started to where we are now.
There is no straight shot.
That is the way programming goes. It’s not a straight shot from way over here to way over there. It’s a wandering path that, if we’re paying attention, won’t wander terribly far from the imaginary direct path … but it will wander. We’re exploring a forest here, and we have to navigate around the trees and the bears that want to bite us. That is the way programming goes.
Next time, the programming will probably simplify Transaction, since it is now tracking more information than we really need.
And we need to explore whether our type hierarchy is quite right. I think I’d like things better if it went straight down rather than its current shape. We might save ourselves one or two type checks if we can get it just right.
We’ll explore that as we move forward. See you then!