Kotlin 242 - Procedural Asteroids??
GitHub Decentralized Repo
GitHub Centralized Repo
GitHub Flat Repo
I’m going to try Asteroids in a third fashion, this time more procedurally. I just want to see what it would look like.
My friend GeePaw Hill walked me through setting up a new repo. The incantations required are bizarre and I have no hope of remembering. the current version is linked above as “Flat Repo” and any of the first few commits should serve as a good thing to clone for another project on my machine.
My tentative plan is this: I’m going to try to do as procedural a version of Asteroids as I can manage within Kotlin. I will create no classes (other than data classes as structs), no class-specific behavior. I’ll do fixed allocations of the asteroids, missiles, ship, and saucer. Once we get into the game cycle, it’ll be function calls all the way down.
Why am I doing such a thing? Just to see what happens. I think it’ll be interesting and we’ll probably see some very different-looking code compared to the other two versions.
Now, the original program was written in assembler, but it pretty much looks like ifs and gotos and functions. We won’t do any gotos, but I think what we get may have a lot of similarity to that style. One thing that I won’t emulate is the data storage. In the original, everything was packed into the smallest number of bits possible. I see no reason to do that, but it should be pretty clear how we might go about it if we needed to.
I do plan to do the thing with TDD, so that we’ll have decent tests as well as the ability to look at the screen and see what happens.
But I don’t know how it’ll go, and I don’t even know how far I’ll push it. The idea is to find out what happens.
Beginning
I have a test running:
class HookupTest {
@Test
fun `hook up`() {
assertThat(1+1).isEqualTo(2)
}
}
Woot!
I will, of course, need to use classes for the tests, because that’s how we do it. The question before me now is what test I should write first. What should I make work first?
I think that asteroids are probably simplest. Missiles time out and have to start based on the ship or saucer’s position, the ship has controls, and the saucer has behavior. So asteroids it is.
We’ll be feeling out way through the design of the objects’ data.
Also, arrgh. Immediately I hate the no classes idea, because I want to have points and velocity objects, not just simple x and y values. We’ll try it for a while. This may be a really irritating task I’ve set before myself.
Here’s what I did as a test and code:
@Test
fun `move an asteroid`() {
val asteroid = Asteroid(100.0, 100.0, 10.0, -10.0)
moveAsteroid(asteroid, 0.5)
assertThat(asteroid.x).isEqualTo(105.0, within(0.01))
assertThat(asteroid.y).isEqualTo(95.0, within(0.01))
}
I’m supposing that the Asteroid is a data class with x and y position and dx and dy velocity in a distance per second form. I’m supposing that the move
function takes a time in seconds, a half second here.
The code is this:
data class Asteroid(var x: Double, var y: Double, val dx: Double, val dy: Double)
fun moveAsteroid(asteroid: Asteroid, deltaTime: Double) {
with (asteroid) {
x += dx*deltaTime
y += dy*deltaTime
}
}
Test is green. Commit: initial Asteroid and moveAsteroid().
I’m putting the code right in the test file for now, with the intent to move it out if and when I think it’s somewhat right. That will be very soon, I think. And I do think that this move code wil work for any of our objects, but until we have more than one, I’m not sure how to name things. We’ll probably wind up with an interface on our data classes, assuming that’s even possible. A quick search suggests that it is.
We’ll want a type field in the objects. I wonder if I want an interface, or maybe an abstract class.
No. It’s way too early to be deciding that. We’ll work on Asteroid a bit more, perhaps getting to the point where we can put one on the screen, and then we’ll do another object, probably the ship. Belay all that speculation about interfaces and abstract classes. We’re not that smart.
What else might I do this morning? I have in mind a very short session, just enough to get my feet wet. I think we should look at the provided OPENRNDR main:
package com.ronjeffries.flat
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.loadFont
import org.openrndr.draw.loadImage
import org.openrndr.draw.tint
import kotlin.math.cos
import kotlin.math.sin
fun main() = application {
configure {
width = 768
height = 576
}
program {
val image = loadImage("data/images/pm5544.png")
val font = loadFont("data/fonts/default.otf", 64.0)
extend {
drawer.drawStyle.colorMatrix = tint(ColorRGBa.WHITE.shade(0.2))
drawer.image(image)
drawer.fill = ColorRGBa.PINK
drawer.circle(cos(seconds) * width / 2.0 + width / 2.0, sin(0.5 * seconds) * height / 2.0 + height / 2.0, 140.0)
drawer.fontMap = font
drawer.fill = ColorRGBa.WHITE
drawer.text("OPENRNDR", width / 2.0, height / 2.0)
}
}
}
As written, that draws a bouncing pink ball on a small screen:
Let’s make our asteroid move like the ball, sort of. I’ll move its code out into the main side, out of the tests. Idea politely moves the data class and function. Tests still green. Commit: move Asteroid code over to main.
In no particular order, I modify the test:
@Test
fun `move an asteroid`() {
val asteroid = Asteroid(100.0, 100.0, 10.0, -10.0)
moveAsteroid(asteroid, 200.0, 200.0, 0.5)
assertThat(asteroid.x).isEqualTo(105.0, within(0.01))
assertThat(asteroid.y).isEqualTo(95.0, within(0.01))
}
I mean the 200s there to be width and height. I change the function:
fun moveAsteroid(asteroid: Asteroid, width: Double, height: Double, deltaTime: Double) {
with (asteroid) {
x += dx*deltaTime
y += dy*deltaTime
}
}
I write a new test:
@Test
fun `asteroid wraps high`() {
val asteroid = Asteroid(95.0, 98.0, 10.0, 10.0)
moveAsteroid(asteroid, 100.0, 100.0, 1.0)
assertThat(asteroid.x).isEqualTo(5.0, within(0.01))
assertThat(asteroid.y).isEqualTo(8.0, within(0.01))
}
Test should be red with 105.0. It is. Enhance moveAsteroid
:
fun moveAsteroid(asteroid: Asteroid, width: Double, height: Double, deltaTime: Double) {
with (asteroid) {
x += dx*deltaTime
if ( x > width ) x -= width
y += dy*deltaTime
if ( y > height ) y -= width
}
}
Expect green. Yes. Could commit but won’t. New test:
@Test
fun `asteroid wraps low`() {
val asteroid = Asteroid(5.0, 8.0, -10.0, -10.0)
moveAsteroid(asteroid, 100.0, 100.0, 1.0)
assertThat(asteroid.x).isEqualTo(95.0, within(0.01))
assertThat(asteroid.y).isEqualTo(98.0, within(0.01))
}
I think I did the arithmetic correctly. Test expecting red. Yes. Enhance:
fun moveAsteroid(asteroid: Asteroid, width: Double, height: Double, deltaTime: Double) {
with (asteroid) {
x += dx*deltaTime
if ( x > width ) x -= width
if ( x < 0 ) x += width
y += dy*deltaTime
if ( y > height ) y -= width
if ( y < 0 ) y += width
}
}
Test is green. Commit: moveAsteroid wraps width and height.
Now in the main, I have this:
fun main() = application {
configure {
width = 512
height = 512
}
program {
// val image = loadImage("data/images/pm5544.png")
val font = loadFont("data/fonts/default.otf", 64.0)
val asteroid = Asteroid(512.0, 512.0, 100.0, -90.0)
var lastTime = 0.0
var deltaTime = 0.0
extend {
drawer.fill = ColorRGBa.WHITE
drawer.stroke = ColorRGBa.RED
deltaTime = seconds - lastTime
lastTime = seconds
moveAsteroid(asteroid, width + 0.0, height + 0.0, deltaTime)
with (asteroid) {
x += dx*deltaTime
if ( x > width) x -= width
if ( y > height ) y -= height
if ( x < 0 ) x += width
if ( y < 0 ) y += width
y += dy*deltaTime
}
drawer.circle(asteroid.x, asteroid.y, 32.0)
drawer.fontMap = font
drawer.fill = ColorRGBa.WHITE
drawer.text("Asteroids™", width / 2.0, height / 2.0)
}
}
}
That should fly a white circle with a thin red border up and to the right (x is - upward in OPENRNDR), wrapping.
It does. The thing above is a movie, refresh if it doesn’t move. Let’s sum up.
Summary
I wonder how far I’m willing to push this. It’s really flying in the face of how I like to program. Imagine having an asteroid and not giving it a method to move itself. Eeek.
However, we do have a function written and tested that can move an asteroid. Soon, but not yet, we’ll write a function to draw it. And so on.
This is definitely feeling weird to me but I’d like to see how it takes shape. We’ll try it for a while.
See you next time!