Kotlin 128: Splat? Navigator?
Options abound. further cleanup of Flyer seems prudent. But it might be fun to do the Splat. So many choices. Splat wins.
I could joyfully refactor for a long time, because I find a sort of meditative value in it, because I learn new techniques with my tools, and because looking at things from a design angle tends to make my designs better. At least that’s my story.1
I’ve allowed the Flyer object to grow, and it has grown to be rather incoherent, owing to the fact that the single class covers the disparate behavior of asteroids, missiles, and the ship. Putting multiple different behaviors into a single class tends to create conditional code, methods that not all the objects need, and generally a sort of mess. Now I claim that I have allowed this to go on for as long as it has because I want to see what kind of shape a program takes when I use the notion of flyers in space to handle the game’s internal needs, such as score keeping, spawning new ships, and the like.
This claim could be called a bit bogus because, after all, each of the “special” flyers has its own class. It’s just the flying ones that are all in the same class. One could ask why, if we’re so willing to create separate non-flying flyer classes, we insist on keeping the flying ones all handled by the same class.
I guess my real answer is “I want to see what happens if I do this”. What has happened so far is that little slices of the flyers’ behavior are getting pulled out into separate helper objects. The Controls object was the first of these. It connects to the keyboard and uses key presses to trigger turning, accelerating, and firing missiles. There’s a bit of a trick going on here: all the flyers get an instance of Controls (because just one of them needs it) and they are given an instance that is not wired to the keyboard, so that it will never see any presses and therefore never tell the asteroid to hang a left or fire a missile back at the ship. Only the Controls instance that is given to the ship receives keyboard information.
You could argue that it would be “better” if the flyers that can’t turn or fire were given a different kind of Controls, one that is inherently unable to give commands, instead of just not hooked up.
It gets “worse”. All the flyers have a heading, a position, a velocity, and an acceleration. Of these, position and velocity make sense, because the flyers move, in a straight line. Acceleration and heading are not so valuable.
What are you getting at, Ron?
I’m not drawing conclusions, yet. I’m observing the way things are and thinking about the way they could be. If some of the “could be” options seem tasty enough, we might swim over that way.
That said, I am beginning to think that if each flyer contained a “navigator” or “dynamics” or even a smarter “controls”, we could defer all the position, velocity, and so on over to the navigator. Objects that want to know more could ask the flyer, which would ask its navigator. Most objects care only about position. Ships care about more, so the shipView might ask more questions. During flying, however, we already message the controls:
override fun update(deltaTime: Double): List<IFlyer> {
tick(deltaTime)
val objectsToAdd = controls.control(this, deltaTime)
move(deltaTime)
return objectsToAdd
}
override fun move(deltaTime: Double) {
position = (position + velocity * deltaTime).cap()
}
If the controls
kept position up to date internally, all this might be more clear.
The more I think about this, the more I think it’s a good idea. Getting from here to there might be challenging, and that’s always a learning opportunity. But there is another object that, for some reason, is appealing to me.
Splat
When a missile dies, it is supposed to make a little splat, a sort of brief sparkling effect. With our current scheme, we’d have missiles finalize by creating a splat, and the splat would sparkle for a while and then its time would expire, and it would be swept away, into oblivion, never to sparkle again.
One good reason for doing this is that it is a new kind of flyer, and therefore might make more of a mess in Flyer class, and the messier it gets the better idea we’ll have of how to fix it. Or to look for a job where the code isn’t so crufty. Could go either way.
Let’s do the Splat, since we haven’t added a feature for a while. As things stand now, we’ll need to create the FLyer, with position wherever the missile was when it finalized. Splats don’t move, they just sparkle. We’ll need a SplatView to do the sparkle and, of course, the Splat itself must finalize.
We’ll have to relearn how all this works. It was literally hours ago when I last knew.
Let’s do a test to cause the missile to create a splat.
@Test
fun `missile demise creates a splat`() {
val ship = Flyer.ship(Vector2(100.0,100.0))
val missile = Flyer.missile(ship)
val splatList = missile.finalize()
assertThat(splatList.size).isEqualTo(1)
}
As expected, the test fails, because the missile isn’t returning a splat yet. Missiles are created thus:
fun missile(ship: Flyer): Flyer {
val missileKillRadius = 10.0
val missileOwnVelocity = Velocity(SPEED_OF_LIGHT / 3.0, 0.0).rotate(ship.heading)
val missilePos: Point = ship.position + Velocity(2*ship.killRadius + 2 * missileKillRadius, 0.0).rotate(ship.heading)
val missileVel: Velocity = ship.velocity + missileOwnVelocity
val flyer = Flyer(missilePos, missileVel, missileKillRadius, 0, false, MissileView())
flyer.lifetime = 3.0
return flyer
}
We’ll need a MissileFinalizer:
fun missile(ship: Flyer): Flyer {
val missileKillRadius = 10.0
val missileOwnVelocity = Velocity(SPEED_OF_LIGHT / 3.0, 0.0).rotate(ship.heading)
val missilePos: Point = ship.position + Velocity(2*ship.killRadius + 2 * missileKillRadius, 0.0).rotate(ship.heading)
val missileVel: Velocity = ship.velocity + missileOwnVelocity
val flyer = Flyer(missilePos, missileVel, missileKillRadius, 0, false, MissileView())
flyer.lifetime = 3.0
flyer.finalizer = MissileFinalizer()
return flyer
}
The complexity of these creators is one of the signs that something is funky in Flyer: it’s hard to create one and each one has a different combination of settings. We’re working toward improving this. Just now, we’re making it a bit worse.
IDEA wants to help me make a MissileFinalizer.
class MissileFinalizer(): IFinalizer {
override fun finalize(missile: Flyer): List<IFlyer> {
return listOf(Flyer.splat(missile))
}
}
Now we have the minor issue of creating a splat, given a missile.
fun splat(missile: Flyer): Flyer {
val splat = Flyer(missile.position, Velocity.ZERO, -Double.MAX_VALUE, 0, true, SplatView())
splat.lifetime = 5.0
return splat
}
I truly hate the hoops I have to jump through to create these things. Now we need SplatView.
This one takes some effort to get what I want. I borrow the algorithm from my Codea splat, which I doubtless stole from the original arcade game or some other example. Here’s what I have now:
class SplatView: FlyerView {
private val rot = random(0.0, 359.0)
private var size = 2.0
private var radius = 6.0
private val sizeStep = (10.0-size)/60.0
private val radiusStep = (1.0-radius)/60.0
private val points = listOf(
Point(-2.0,0.0), Point(-2.0,-2.0), Point(2.0,-2.0), Point(3.0,1.0), Point(2.0,-1.0), Point(0.0,2.0), Point(1.0,3.0), Point(-1.0,3.0), Point(-4.0,-1.0), Point(-3.0,1.0)
)
override fun draw(splat: Flyer, drawer: Drawer) {
drawer.stroke = ColorRGBa.WHITE
drawer.fill = ColorRGBa.WHITE
drawer.scale(10.0)
drawer.rotate(rot)
for (point in points) {
drawer.circle(size*point.x, size*point.y, radius/size)
}
radius += radiusStep/splat.lifetime
size += sizeStep/splat.lifetime
}
}
Let me explain. No, there is too much. Let me sum up.2
The splat is built from ten points
at the provided distances from the current screen position. Over the course of the splat’s lifetime, we want the points to drift outward, like an explosion. This is controlled by size
, which we want to vary from 2 to 10. Meanwhile, the size of the individual dots should get smaller, as the fragments cool down, from a radius value of 6 down to 1.
So when the splat is created, we calculate the amount of change per 60th of a second to adjust the values (over the course of one second). In use, we divide by the splat’s lifetime, so that if it lives longer than a second, the effect will be drawn out over that time.
Then there’s the drawer.scale
, which I tweaked, along with the other values, until I got what I wanted. That could all be a lot better … but the effect is about what we want. I’ll make a short video.
This code needs a fair amount of improving. Not least, we should provide some kind of “tween” mechanism for moving the values from here to there in an orderly fashion. And the use of scale together with the other values makes things harder to understand. I always get into this kind of trouble, scaling for effect vs scaling to fit objects of “known” size on to a screen of given dimensions.
I think, for now, I’ll adjust so that we don’t need scale, and shorten the splat lifetime, which is currently set to ten seconds, if I recall.
I wind up here:
override fun draw(splat: Flyer, drawer: Drawer) {
drawer.stroke = ColorRGBa.WHITE
drawer.fill = ColorRGBa.WHITE
drawer.rotate(rot)
for (point in points) {
drawer.circle(size*point.x, size*point.y, radius)
}
radius += radiusStep/splat.lifetime
size += sizeStep/splat.lifetime
}
With a splat lifetime of two seconds, it looks about right. Let’s have a break and then sum up.
Summary
Curiously, creating this new flyer was actually pretty simple. We had to create a new MissileFinalizer to create the splat, and to plug it into the missile creation code. That creation code, like all the flyers’ creation code, is rather complicated. Yes, we do need to specify a fair amount of information that is all actually pertinent, but it’s still a pain.
The Splat itself has no notable behavior, but it does need to display itself, so we gave it a SplatView.
The SplatView is actually one of the more complicated views we’ve made so far, because it has to display ten dots at a growing distance out and a shrinking radius, over the lifetime of the splat. That growing and shrinking was coded ad hoc, looking at the screen. It could have been worked out logically, and perhaps it should have been.
We could have started with the missile itself, which has a drawn radius equal to its kill radius. Except that it is drawn at scale 3 …
class MissileView: FlyerView {
override fun draw(missile: Flyer, drawer: Drawer) {
drawer.stroke = ColorRGBa.WHITE
drawer.fill = ColorRGBa.WHITE
drawer.scale(3.0,3.0)
drawer.circle(Point.ZERO, missile.killRadius)
}
}
What this means, in effect, is that the missile appears three times larger on the screen than it “really is”, so that, in principle, you could see a missile touching an asteroid and yet not exploding it. You’d need better eyes than mine to be sure of that. We might do well to recast MissileView this way:
class MissileView: FlyerView {
override fun draw(missile: Flyer, drawer: Drawer) {
drawer.stroke = ColorRGBa.WHITE
drawer.fill = ColorRGBa.WHITE
// drawer.scale(3.0,3.0) 3 moved to the draw?
drawer.circle(Point.ZERO, missile.killRadius*3.0)
}
}
The two forms are the same but which one should we prefer? Must think about that.
But anyway … we could have said, OK, the missile draws at a radius of 30, or 3 times its kill radius. A splat mark should start with fragments about that size, covering an area about the same as a small asteroid, and growing to about the size of a middle-sized one. Then we could have worked out the figures that way. Of course, it turns out that with our present scheme of views, they, too, include a scale call to make things look right. And … there’s nothing really wrong with that.
I’m sure that the original primordial splat was drawn on graph paper and scaled into the screen directly, because pixels were large in those days. So we could argue that my splat should be dimensioned in universal coordinates and drawn in the standard screen scale.
So, yes, we could do better, and maybe we should. But the oddities are all contained in small places like a specific view’s draw method, so at least the issues are mostly isolated.
What have I learned? I think I’d list these learnings.
-
As we know, creation of a Flyer is a hassle, with a lot of member variables. The defaults are reasonable but sometimes we need to tick through them to set something we care about. Possibly using named argument style would help with this. Possibly more than one constructor on the class.
-
Creating a new Finalizer to make some new effect is easy. That feature seems to work well.
-
The general flow of creation and destruction seems to be OK. We’ve not needed to destroy something or create something in a surprising new place. That aspect of the design is apparently OK.
-
New flyer views are straightforward. They can assume that the screen is set to scale and screen translated to the center of the object being viewed. Quite convenient.
-
A specialized view like the splat can create new members to help it do its work.
-
We could probably benefit from having a “tween” capability that modifies variables according to a pattern. There must be tween capability in OPENRNDR, or somewhere. Or we can roll our own. Tweens often act like they are on their own thread. I don’t think we’ll need that … but we might well make a tween flyer.
-
The Flyer / Special Flyer structure is holding up well.
Bottom line, this went smoothly and, for me, reduces the pressure to improve the internals of Flyer. The breakouts we have already done are bearing a lot of the weight, once we finally manage to create the Flyer we want.
I am pleased. See you next time!