Kotlin 287 - Drawing
GitHub Decentralized Repo
GitHub Centralized Repo
GitHub Flat Repo
Yesterday as I was summing up, I had an idea for a different way of handling the multiple shapes for asteroids. Let’s try it.
When we activate an asteroid, we provide it with a random integer from 0 through 3, indicating the shape it should have:
When we draw a space object, we check to see whether it is an asteroid, and if so, look up its shape and draw it:
fun draw(spaceObject: SpaceObject, drawer: Drawer, deltaTime: Double) {
drawer.isolated {
val scale = 4.0 *spaceObject.scale
drawer.translate(spaceObject.x, spaceObject.y)
drawer.scale(scale, scale)
drawer.rotate(spaceObject.angle)
drawer.stroke = ColorRGBa.WHITE
drawer.strokeWeight = 1.0/scale
shipSpecialHandling(spaceObject, drawer, deltaTime)
if ( spaceObject.type == SpaceObjectType.ASTEROID) {
drawer.lineStrip(rocks[spaceObject.pointsIndex])
} else {
drawer.lineStrip(spaceObject.type.points)
}
}
}
Note that there is also special handling for the ship, to draw its flare:
private fun shipSpecialHandling(spaceObject: SpaceObject, drawer: Drawer, deltaTime: Double) {
if (spaceObject.type == SpaceObjectType.SHIP) {
dropScale = max(dropScale - U.ShipDropInScale*deltaTime, 1.0)
drawer.scale(dropScale, dropScale)
if (Controls.accelerate && Random.nextInt(1, 3) == 1) {
drawer.lineStrip(shipFlare)
}
}
}
If we had unique object types for the space objects, we’d give them unique draw
methods. We are not allowing ourselves to use methods in this version, as some kind of penance for our sins.
Another way we could go would be to create a drawing Component, and when it comes time to draw, we could loop over those. But if we did, it seems that there would still need to be this type check. And our design just isn’t big enough to justify that E-C-S style anyway.
I had another idea yesterday:
What we could do, I suppose, would be to provide a unique draw function, not a points function, maybe named
drawPoints
, in eachenum
element, and call that fromdraw
. In fact, that’s such an interesting idea that I’ll probably try it next time, just to see how it works out. I’ve made a note of it.
Here we are at next time, so let’s do it.
The “Plan”
The notion is that we’ll create a few different drawing functions, an ordinary one for saucer and missiles to use, and special ones for ship and asteroids. The ordinary one will just draw the points from the enum, the ship one will include the flare logic, and the asteroid one will pick the right shape out of the table.
It seems to me that the function in question needs just two parameters, the drawer, and the space object to be drawn.
Questions
- Is there any sensible way to test-drive this idea?
- How can I do this very incrementally, tiny steps that I can commit?
Answers
- 1
- I don’t see a useful way to TDD this, but let’s see if #2’s solution helps.
- 2
- Maybe we could do each drawing function separately, allowing the new member variable to be null. We could check in the current
draw
to see whether there is a function to call and if there is, call it and return, otherwise do what we do now. -
That would let us do one drawing function at a time.
- 1 Revisited
- I still don’t see a useful role for tests.
Do It
Let’s get started and see what we can see. We want a new member in the enum
, a function from Drawer and SpaceObject, to Unit.
First Obstacle
I can’t figure out how to make this function parameter nullable, so I have just put empty functions in each position for now. I don’t like nulls anyway.
enum class SpaceObjectType(
val points: List<Vector2>,
val killRadius: (SpaceObject) -> Double
val draw: (Drawer, SpaceObject) -> Unit
) {
ASTEROID(asteroidPoints, asteroidRadius,
{drawer, spaceObject -> }),
SHIP(shipPoints, shipRadius,
{drawer, spaceObject -> }),
SAUCER(saucerPoints, saucerRadius,
{drawer, spaceObject -> }),
MISSILE(missilePoints, missileRadius,
{drawer, spaceObject -> }),
SAUCER_MISSILE(missilePoints, missileRadius,
{drawer, spaceObject -> })
}
I can see two ways to go from here. One is to put an if in the existing draw and incrementally extend it to call one after another of these functions.
The second idea is to just let them remain empty, call unconditionally, and fill them in until they’re all done.
The third idea — I almost always get another idea after I predict two — is to draw the object’s points in all five cases, and then improve them. That will take us back to one kind of asteroid, but we’ll fix that quickly.
Let’s do that:
enum class SpaceObjectType(
val points: List<Vector2>,
val killRadius: (SpaceObject) -> Double,
val draw: (Drawer, SpaceObject) -> Unit
) {
ASTEROID(asteroidPoints, asteroidRadius,
{ drawer, spaceObject -> drawer.lineStrip(spaceObject.type.points) }),
SHIP(shipPoints, shipRadius,
{ drawer, spaceObject -> drawer.lineStrip(spaceObject.type.points) }),
SAUCER(saucerPoints, saucerRadius,
{ drawer, spaceObject -> drawer.lineStrip(spaceObject.type.points) }),
MISSILE(missilePoints, missileRadius,
{ drawer, spaceObject -> drawer.lineStrip(spaceObject.type.points) }),
SAUCER_MISSILE(missilePoints, missileRadius,
{ drawer, spaceObject -> drawer.lineStrip(spaceObject.type.points) })
}
fun draw(spaceObject: SpaceObject, drawer: Drawer, deltaTime: Double) {
drawer.isolated {
val scale = 4.0 *spaceObject.scale
drawer.translate(spaceObject.x, spaceObject.y)
drawer.scale(scale, scale)
drawer.rotate(spaceObject.angle)
drawer.stroke = ColorRGBa.WHITE
drawer.strokeWeight = 1.0/scale
shipSpecialHandling(spaceObject, drawer, deltaTime)
spaceObject.type.draw(drawer, spaceObject)
}
}
I left in the special handling, because why not. Test in game, expecting everything fine except just one kind of asteroid. That’s exactly what happens.
So now what? Well, I’m here to deal with the asteroids, so let’s do them first.
enum class SpaceObjectType(
val points: List<Vector2>,
val killRadius: (SpaceObject) -> Double,
val draw: (Drawer, SpaceObject) -> Unit
) {
ASTEROID(asteroidPoints, asteroidRadius,
{drawer, spaceObject -> drawer.lineStrip(rocks[spaceObject.pointsIndex])}),
SHIP(shipPoints, shipRadius,
{ drawer, spaceObject -> drawer.lineStrip(spaceObject.type.points) }),
SAUCER(saucerPoints, saucerRadius, { drawer, spaceObject -> drawer.lineStrip(spaceObject.type.points) }),
MISSILE(missilePoints, missileRadius,
{ drawer, spaceObject -> drawer.lineStrip(spaceObject.type.points) }),
SAUCER_MISSILE(missilePoints, missileRadius,
{ drawer, spaceObject -> drawer.lineStrip(spaceObject.type.points) })
}
I expect to see all the different asteroids now. I do. We could be committing this, by the way, but it’s just taking moments, so let’s not. We’ll wait until the feature is done. Hill would disagree.
OK, we’ll commit. Commit: asteroid, saucer, and missile drawing done via enum draw function.
OK, ship. That’s kind of complicated, so I’ll create a function to call.
Ah, doing ship shows me that my functions need deltaTime if I’m to handle drop-in. OK, change them.
val drawShip = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.lineStrip(spaceObject.type.points)
dropScale = max(dropScale - U.ShipDropInScale*deltaTime, 1.0)
drawer.scale(dropScale, dropScale)
if (Controls.accelerate && Random.nextInt(1, 3) == 1) {
drawer.lineStrip(shipFlare)
}
}
enum class SpaceObjectType(
val points: List<Vector2>,
val killRadius: (SpaceObject) -> Double,
val draw: (Drawer, SpaceObject, Double) -> Unit
) {
ASTEROID(asteroidPoints, asteroidRadius,
{drawer, spaceObject, deltaTime -> drawer.lineStrip(rocks[spaceObject.pointsIndex])}),
SHIP(shipPoints, shipRadius, drawShip),
SAUCER(saucerPoints, saucerRadius,
{ drawer, spaceObject, deltaTime -> drawer.lineStrip(spaceObject.type.points) }),
MISSILE(missilePoints, missileRadius,
{ drawer, spaceObject, deltaTime -> drawer.lineStrip(spaceObject.type.points) }),
SAUCER_MISSILE(missilePoints, missileRadius,
{ drawer, spaceObject, deltaTime -> drawer.lineStrip(spaceObject.type.points) })
}
I removed the shipSpecialHandling call:
fun draw(spaceObject: SpaceObject, drawer: Drawer, deltaTime: Double) {
drawer.isolated {
val scale = 4.0 *spaceObject.scale
drawer.translate(spaceObject.x, spaceObject.y)
drawer.scale(scale, scale)
drawer.rotate(spaceObject.angle)
drawer.stroke = ColorRGBa.WHITE
drawer.strokeWeight = 1.0/scale
spaceObject.type.draw(drawer, spaceObject, deltaTime)
}
}
I expect perfection. Yes, all good. Commit: ship completely drawn by enum draw function.
However, now that this is in place I can change something that has been bugging me for ages:
The missiles are square. Because I only had the lineStrip operation, I couldn’t make them round as I would have liked. Now we can do that.
val drawShip = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.lineStrip(spaceObject.type.points)
dropScale = max(dropScale - U.ShipDropInScale*deltaTime, 1.0)
drawer.scale(dropScale, dropScale)
if (Controls.accelerate && Random.nextInt(1, 3) == 1) {
drawer.lineStrip(shipFlare)
}
}
val drawShipMissile = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.circle(Vector2.ZERO, spaceObject.type.killRadius(spaceObject))
}
val drawSaucerMissile = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.stroke = ColorRGBa.RED
drawer.fill = ColorRGBa.RED
drawer.circle(Vector2.ZERO, spaceObject.type.killRadius(spaceObject))
}
This will give me round missiles, white for the ship and red for the saucer.
It does, but I notice a bug: I’m not getting the drop in any more. I put the code in the wrong order:
val drawShip = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.lineStrip(spaceObject.type.points) // <== too soon
dropScale = max(dropScale - U.ShipDropInScale*deltaTime, 1.0)
drawer.scale(dropScale, dropScale)
if (Controls.accelerate && Random.nextInt(1, 3) == 1) {
drawer.lineStrip(shipFlare)
}
}
Fix that:
val drawShip = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
dropScale = max(dropScale - U.ShipDropInScale*deltaTime, 1.0)
drawer.scale(dropScale, dropScale)
drawer.lineStrip(spaceObject.type.points) // <== just right
if (Controls.accelerate && Random.nextInt(1, 3) == 1) {
drawer.lineStrip(shipFlare)
}
}
OK that’s working as intended. The missiles are kind of large, but that’s fine by me.
Commit: missiles are now round. Saucer missiles are red.
Let’s reflect.
Reflection
We’ve replaced type checking code with a function lookup:
fun draw(spaceObject: SpaceObject, drawer: Drawer, deltaTime: Double) {
drawer.isolated {
val scale = 4.0 *spaceObject.scale
drawer.translate(spaceObject.x, spaceObject.y)
drawer.scale(scale, scale)
drawer.rotate(spaceObject.angle)
drawer.stroke = ColorRGBa.WHITE
drawer.fill = ColorRGBa.WHITE
drawer.strokeWeight = 1.0/scale
spaceObject.type.draw(drawer, spaceObject, deltaTime)
}
}
We used to check for ship and asteroid directly, and if we had had our round colored missiles, we’d have been checking those types as well.
Instead, we have placed type-specific drawing functions in the enum
that specifies type-dependent details for our space objects. You can think of this as “we have placed a function pointer in the enum”, or you can think of it is “we have faked a method in the enum class”. Either is accurate, and either way, I think the code is a bit simpler in the draw
function, but the details of drawing are a tiny bit harder to find. Only a little bit, because they’re all right here:
val drawShip = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
dropScale = max(dropScale - U.ShipDropInScale*deltaTime, 1.0)
drawer.scale(dropScale, dropScale)
drawer.lineStrip(spaceObject.type.points)
if (Controls.accelerate && Random.nextInt(1, 3) == 1) {
drawer.lineStrip(shipFlare)
}
}
val drawShipMissile = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.circle(Vector2.ZERO, spaceObject.type.killRadius(spaceObject))
}
val drawSaucerMissile = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.stroke = ColorRGBa.RED
drawer.fill = ColorRGBa.RED
drawer.circle(Vector2.ZERO, spaceObject.type.killRadius(spaceObject))
}
enum class SpaceObjectType(
val points: List<Vector2>,
val killRadius: (SpaceObject) -> Double,
val draw: (Drawer, SpaceObject, Double) -> Unit
) {
ASTEROID(asteroidPoints, asteroidRadius,
{drawer, spaceObject, deltaTime -> drawer.lineStrip(rocks[spaceObject.pointsIndex])}),
SHIP(shipPoints, shipRadius, drawShip),
SAUCER(saucerPoints, saucerRadius,
{ drawer, spaceObject, deltaTime -> drawer.lineStrip(spaceObject.type.points) }),
MISSILE(missilePoints, missileRadius, drawShipMissile),
SAUCER_MISSILE(missilePoints, missileRadius, drawSaucerMissile)
}
I think we’d like to clean this up a bit, make all the entries the same.
val drawAsteroid = {drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.lineStrip(rocks[spaceObject.pointsIndex])
}
val drawSaucer = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.lineStrip(spaceObject.type.points)
}
val drawSaucerMissile = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.stroke = ColorRGBa.RED
drawer.fill = ColorRGBa.RED
drawer.circle(Vector2.ZERO, spaceObject.type.killRadius(spaceObject))
}
val drawShip = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
dropScale = max(dropScale - U.ShipDropInScale*deltaTime, 1.0)
drawer.scale(dropScale, dropScale)
drawer.lineStrip(spaceObject.type.points)
if (Controls.accelerate && Random.nextInt(1, 3) == 1) {
drawer.lineStrip(shipFlare)
}
}
val drawShipMissile = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.circle(Vector2.ZERO, spaceObject.type.killRadius(spaceObject))
}
enum class SpaceObjectType(
val points: List<Vector2>,
val killRadius: (SpaceObject) -> Double,
val draw: (Drawer, SpaceObject, Double) -> Unit
) {
ASTEROID(asteroidPoints, asteroidRadius, drawAsteroid),
SHIP(shipPoints, shipRadius, drawShip),
SAUCER(saucerPoints, saucerRadius, drawSaucer),
MISSILE(missilePoints, missileRadius, drawShipMissile),
SAUCER_MISSILE(missilePoints, missileRadius, drawSaucerMissile)
}
Yes, better. We could probably remove the points field from the enum
now, like this:
val drawAsteroid = {drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.lineStrip(rocks[spaceObject.pointsIndex])
}
val drawSaucer = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.lineStrip(saucerPoints)
}
val drawSaucerMissile = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.stroke = ColorRGBa.RED
drawer.fill = ColorRGBa.RED
drawer.circle(Vector2.ZERO, spaceObject.type.killRadius(spaceObject))
}
val drawShip = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
dropScale = max(dropScale - U.ShipDropInScale*deltaTime, 1.0)
drawer.scale(dropScale, dropScale)
drawer.lineStrip(shipPoints)
if (Controls.accelerate && Random.nextInt(1, 3) == 1) {
drawer.lineStrip(shipFlare)
}
}
val drawShipMissile = { drawer: Drawer, spaceObject: SpaceObject, deltaTime: Double ->
drawer.circle(Vector2.ZERO, spaceObject.type.killRadius(spaceObject))
}
enum class SpaceObjectType(
val killRadius: (SpaceObject) -> Double,
val draw: (Drawer, SpaceObject, Double) -> Unit
) {
ASTEROID(asteroidRadius, drawAsteroid),
SHIP(shipRadius, drawShip),
SAUCER(saucerRadius, drawSaucer),
MISSILE(missileRadius, drawShipMissile),
SAUCER_MISSILE(missileRadius, drawSaucerMissile)
}
Better run the tests to be sure that’s legit but I think it is. Yes. I was pretty sure there were no tests looking at the points lists. Commit: simplify SpaceObjectType enum.
Now I think we can make a pretty good case that the enum
is slightly more complicated, since it contains a function pointer, but that we’ve reduced complexity there a bit by removing the other member variable entirely. And both of the current members are functions now, one returning kill radius, the other doing the drawing. So the function idea was already there. We’ve added another one but not a new concept. We already paid that price.
This has gone on long enough. Let’s sum up.
Summary
Essentially here we have done the refactoring called, I think, Replace Conditional With Polymorphism. Arguably we’ve only done 90 percent of it or something, because we would normally have separate types for each space object.
Or is it fair to say that we do have separate types and they are named ASTEROID, SHIP, SAUCER, and so on? We’ve sort of invented limited object-oriented programming by putting function pointers in our enum.
Either way, I personally like this solution better than the conditionals, especially since it gave me round missiles for essentially no cost.
I’m glad I had that idea yesterday and that I tried it today.
See you next time! Be kind, to every living thing.