Kotlin 131: When the tweens come marching in.
No, not young people tweens. Tweens are little objects that do interpolation for us. I think we should have them. P.S. Dealt with that little lifetime issue.
Yesterday, running the game, I noticed that when a missile finalized and created a splat, it seemed that there was sometimes an “echo” in the splat: it would do its expanding, fading explosion thing … and then briefly show another twinkle, with a larger radius and even smaller dots. I was able to convince myself that we are not getting a second splat, so I think something is wrong in my hand-written code to iterate the size of the splat and of the ten dots that make it up.
I could probably debug that, but it seems that doing the right thing would be better. The right thing, I believe, is the “tween”. For our purposes, a Tween will be a small object that is given starting and ending values and a duration of time. When we ask the Tween for its value, we’ll give it a parameter of elapsed time. It will linearly interpolate between the starting and ending values and give us the value back.
There are more advanced possibilities for tweens, mostly grouped under the heading of “easing”, which provides a different form of interpolation. Easing functions can be used to cause the value provided to speed up to some max, to slow down as it reaches the end, to bounce, to do damping. I think the convenient, albeit a bit cryptic online description of easing is found at Easing Functions Cheat Sheet. Also frequently referenced is Robert Penner’s Easing Functions which uses actual words to describe a bit about what’s going on and why.
Our Tween will just scratch the surface of what’s possible, because we’re working with a tiny visual effect. Perhaps at some future time we’ll need something better, in which case we’ll do something better. For now, let’s tDD up a simple Tween and then plug it into the splat.
@Test
fun `tween linear interpolation`() {
val tween = Tween(start=5.0, end=10.0, duration=2.0)
assertThat(tween.value(0.0)).isEqualTo(5.0)
}
This should be enough to drive out the class and a little behavior. I’m not going to do much “fake it till you make it” here. I think we can keep the steps small enough without that Jedi mind trick. We’ll see.
I create the class and function this way:
class Tween(private val start: Double, private val end: Double, private val duration: Double) {
fun value(t:Double): Double {
val frac = t/duration
return end*frac + start*(1.0 - frac)
}
}
I expect the test to run. It does. I’ll discuss why I wrote it this way below but let’s get it working by adding in some more checks.
@Test
fun `tween linear interpolation`() {
val tween = Tween(start=5.0, end=10.0, duration=2.0)
assertThat(tween.value(0.0)).isEqualTo(5.0)
assertThat(tween.value(2.0)).isEqualTo(10.0)
assertThat(tween.value(1.0)).isEqualTo(7.5, within(0.01))
assertThat(tween.value(3.0)).isEqualTo(10.0)
assertThat(tween.value(-6.0)).isEqualTo(5.0)
}
The first three tests are in range. The last two specify what I want to have happen when the tween is given an out of range parameter: it should stick to the ends of the range. I expect the first three checks to pass and the last two to fail.
expected: 10.0
but was: 12.5
Right. It ran over the end. We could allow that, but I prefer the pinning. I’ll put in both bits.
import kotlin.math.min
import kotlin.math.max
class Tween(private val start: Double, private val end: Double, private val duration: Double) {
fun value(t:Double): Double {
val fraction = max(0.0, min(t/duration, 1.0,))
return end*fraction + start*(1.0 - fraction)
}
}
This passes the test. I’ve just clamped fraction
to be between zero and one at the ends of the duration.
- Harumph
- Permit me to express a bit of grumpiness. I renamed that temp to
fraction
because Kotlin / IDEA had the audacity to suggest thatfrac
was a typo. I think I’m going to turn that off: it even checks my comments and commits for what it considers to be typos. There’s a time when “helpful” just turns into nitpicking and I think we’re there.
A fanatic might test the opposite direction of the range high to low rather than low to high. I am no fanatic or maybe I am, but we’ll test because we want the tests to show what the little object does.
@Test
fun `tween with odd range`() {
val downTween = Tween(1.0, -1.0, 5.0)
assertThat(downTween.value(0.0)).isEqualTo(1.0)
assertThat(downTween.value(5.0)).isEqualTo(-1.0)
assertThat(downTween.value(2.5)).isEqualTo(0.0)
assertThat(downTween.value(6.0)).isEqualTo(-1.0)
assertThat(downTween.value(-4.0)).isEqualTo(1.0)
}
OK, we’re good. Let’s use the thing in splat, and remind me to talk about my formulation of the code. But first the fun of splat.
fun splat(missile: Flyer): Flyer {
return Flyer(
position = missile.position,
velocity = Velocity.ZERO,
lifetime = 2.0,
view = SplatView()
)
}
class SplatView: FlyerView {
private val rot = random(0.0, 359.0)
private var size = 20.0
private var radius = 30.0
private val sizeStep = (100.0-size)/60.0
private val radiusStep = (5.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(flyer: 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/flyer.lifetime
size += sizeStep/flyer.lifetime
}
}
SplatView is probably the most complicated View we have so far, though the Asteroids are contenders. In our view, we want to have the radius and size change as indicated by the values of radius, size, and the corresponding steps. I don’t even want to know what those things mean. I just want a couple of tweens.
The size is supposed to go from 20 to 100. The radius goes from 30 down to 5. The duration will be the Splat lifetime and the time input the Splat elapsed time. We don’t have access to lifetime when we create the splat. I’m not sure how to correct that, given the way we create the objects. Let’s just put in the same constant for now.
Works perfectly, and I don’t see any echo:
Commit: Splat uses new tween for explosion control. Tied to Splat duration constant 2.0.
Now I wanted to talk about my formulation of the actual computation:
class Tween(private val start: Double, private val end: Double, private val duration: Double) {
fun value(t:Double): Double {
val fraction = max(0.0, min(t/duration, 1.0,))
return end*fraction + start*(1.0 - fraction)
}
}
It is easy to refactor this to something “simpler” but I write the expression that way because it is the way I memorized interpolation Lo! these many years ago. It’s easy to remember because you can say welp, when fraction is zero I want start and when fraction is one I want end, and the *frac and *(1-frac) fall out of that reasoning. If we care about computer time, we have more multiplies and divides than we absolutely need. We can do some high school algebra just for fun.
Expand:
return end*fraction + start*1.0 - start*fraction
Commutativity of +:
return end*fraction - start*fraction + start*1.0
Identity under multiplication:
return end*fraction - start*fraction + start
Distributive law, * over +:
return (end - start)*fraction + start
Commutativity of +:
return start + (end - start)*fraction
Commutativity of *:
return start + fraction*(end - start)
That’s as tight as it gets, as far as I know. However, in my view, it is no longer clear how it works. I prefer the original for easier understanding. My house, my rules. Revert to original.
return end*fraction + start*(1.0 - fraction)
Fun, huh? And yes, I ran the tests every time.
We’ll sum up.
Summary
We test-drove a tiny Tween object that interpolates values over time, and plugged it into Splat, where it works nicely. (We do have the coupling between the constants 2.0 and 2.0, but I think we’ll deal with that shortly.) The Tween class now exists and could be extended to provide for different forms of easing if we wished.
What is somewhat amusing is that this tiny explosion is perhaps the fanciest graphics in the whole program. OK, I admit that I am easily amused.
A tiny step in a good direction. Time to publish and move on to whatever else the day holds in store.
P.S.
I decided to resolve the issue of the Splat needing the time by just creating the view with the same value:
fun splat(missile: Flyer): Flyer {
val lifetime = 2.0
return Flyer(
position = missile.position,
velocity = Velocity.ZERO,
lifetime = lifetime,
view = SplatView(lifetime)
)
}
class SplatView(lifetime: Double): FlyerView {
private val rot = random(0.0, 359.0)
private var sizeTween = Tween(20.0,100.0, lifetime)
private var radiusTween = Tween(30.0, 5.0, lifetime)
Easy enough, makes it clear what’s happening.