Spacewar! 26 - Let's blow some stuff up!
We have this little glitch where when you fire a missile, you die. It’s too bad more weapons don’t work that way but we’re here to have a battle game. I figure to fix that problem, we should rez the missile a ways in front of the ship, not at its center. At a guess, we ought to rez it outside the kill radius. Just a guess.
Here’s missile rezzing as it is now:
-- Ship
function Ship:fireMissile()
if self.missileLoad > 0 and U.Tick - self.timeLastFired > self.MissileMod then
self.timeLastFired = U.Tick
self.missileLoad = self.missileLoad - 1
Missile(self)
end
end
-- Missile
function Missile:init(ship)
self.name = "missile"
self.pos = ship.pos
local velocity = vec2(0,1):rotate(math.rad(ship.heading))
self.vel = ship.vel + velocity
self.maxDistance = WIDTH*1.4
self.distance = 0
self.alive = true
addDrawnAtTop(self)
end
Clearly1, if we set the missile.pos
to ship.pos plus a ways in front
, all will be well. I just have two questions, what’s a ways, and what’s in front. In Main, we find this:
function colliding(obj1, obj2)
if obj1 == nil then return false end
if obj2 == nil then return false end
if obj1 == obj2 then return false end
return obj1.pos:dist(obj2.pos) < 20
end
The magic number 20 is the distance within which we kill, so a ways had better be about that much. Probably a bit more. It would be a shame if something were to … happen … to that nice ship of yours. Also, let’s extract that magic number. Someone taught me never to use manifest constants other than zero and one. I wish I had listened.
U = {}
U.Tick = 0
U.Drawn = {}
U.Touched = {}
U.Running = true
U.MissileKillDistance = 20
function colliding(obj1, obj2)
if obj1 == nil then return false end
if obj2 == nil then return false end
if obj1 == obj2 then return false end
return obj1.pos:dist(obj2.pos) < U.MissileKillDistance
end
There we go. That took less than a minute to do and the code is better. I try to clean things up quickly when I see them, and I have the feeling that I’ve been a bit lax lately. We’ll keep an eye on it. And we weren’t even diverted from our missile mission. Let’s say that “a ways” is 1.5 times the kill distance. What about “in front”?
Well, ships fly in the direction of positive Y. So “a ways in front” is vec2(0, U.MissileKillDistance*1.5)
. There’s another manifest constant but I don’t think naming that one will help. Our vector is only in front, however, if the ship is heading in its positive Y direction, which it usually isn’t. So we have to rotate that vector by the ship rotation and rez there.
Think about that. If the ship were actually headed in the Y direction, then something like (0, 30) would be right in front of it. If the ship is rotated K degrees, we need to rotate the (0, 30) by K degrees to get out in front. Fortunately, vectors know how to do that, and the ship knows its rotation.2
So the missile code looks like this:
function Missile:init(ship)
self.name = "missile"
self.pos = ship.pos + aWaysOutInFront(ship)
local velocity = vec2(0,1):rotate(math.rad(ship.heading))
self.vel = ship.vel + velocity
self.maxDistance = WIDTH*1.4
self.distance = 0
self.alive = true
addDrawnAtTop(self)
end
function aWaysOutInFront(ship)
local aWays = vec2(0, U.MissileKillDistance*1.5)
return aWays:rotate(math.rad(ship.heading))
end
What about that name, aWaysOutInFront
? Is it frivolous? Yes. But if we don’t entertain ourselves, who will? And it is certainly communicative.
It now turns out that a ship can fire a missile and shoot down, not itself, but the other ship. I’ll put a movie below but before I do, I’m going to make the missiles more visible. I think white, and larger.
MOVIE HERE
In the above movie, I fire a missile from my starting position. Then I turn to port and fire a couple of rounds at the enemy. I hit the enemy on the first shot. However, my initial shot wraps around and takes me out. Karma, or a clever plan? You decide.
You may remember that missiles time out and die, leaving a big dot, formerly red, now white. Look what happens if you fire a few in the same direction from the same place:
MOVIE HERE
The first missile times out and becomes large. The second missile hits the stalled one and they kill each other. Then the third missile times out. It’s time we make timed-out missiles die not just sit there like a big blob. Although they do make interesting mines. But no.
However, I noticed something very funny. Watch this:
MOVIE HERE
Yes, that’s right. My ship killed the other ship’s button. I wish I had aimed at the fire button. But probably killing the other guy’s buttons is not part of the design. So we have a design issue: there are objects that properly collide, and objects that don’t. Right now, the buttons are the only ones that don’t. How might we fix that? Among other ideas:
- We could have a new collection
U.Colliders
; - We could have an object property, maybe
canCollide
; - We could have smarter collision behavior on the objects.
What about smarter collision behavior? Remember when I mentioned “double dispatch” a chapter or so back? What if there was a little conversation between colliding objects? Suppose A and B are objects and the collisions logic notices A and B are within the kill radius. It might send A:collide(B)
. If A isn’t killable, like a button, it can ignore the message. If it is killable, like a ship, it sends another message to B, like B:collidingWith(A)
. Then … No, this is getting weird. Not a good idea, Instead let’s just give objects a cannotCollide
property and if either object can’t collide, don’t deal with them:
-- Main
function colliding(obj1, obj2)
if obj1 == nil then return false end
if obj2 == nil then return false end
if obj1.cannotCollide then return false end
if obj2.cannotCollide then return false end
if obj1 == obj2 then return false end
return obj1.pos:dist(obj2.pos) < U.MissileKillDistance
end
-- Button
function Button:init(x, y)
self.name = string.format("Button %f %f", x, y)
self.pos = vec2(x,y)
self.radius = 50
self.pressed = false
self.capturedID = nil
self.cannotCollide = true
addTouched(self)
addDrawnAtBottom(self)
end
I’m not entirely happy with this solution. Weird little properties on objects are kind of a code smell. In addition, if I had not carefully picked cannotCollide
as the name, I’d have had to add the property to Ship and Missile as well as Button. This is not good design. The idea of having a U.Colliders
collection isn’t much better, though, and may be worse. We’d have to maintain it carefully, and we’re already maintaining and updating the main U.Drawn
collection. Speaking of that, look at the code in Main that does the pairwise collision check:
function collisions()
for iObj,obj in pairs(U.Drawn) do
for iOther,other in pairs(U.Drawn) do
if colliding(obj, other) then
U.Drawn[obj] = nil
U.Drawn[other] = nil
end
end
end
end
That code removes the objects from drawn and doesn’t give the objects time to do anything interesting, like explode or cry out and be silenced. Objects should implement a method die
that enables that. Also, when a missile times out, it should die, not just sit there like a blob.
These are not errors: we did these things on purpose, on our way to a robust solution. However, they are the sort of short-term incremental change that can stick with us forever. Are we being too ad-hoc right now, leaving too much cruft around? Should we bear down a bit more on expressing our intention rather than just writing the most straightforward thing?
Kent Beck used to ask us “what is the simplest thing that could possibly work?” I have taken to doing the simplest thing that could possibly work, which is not what he advised at all. He advised us to think: I choose, often, to act. I do that for two reasons:
- It is easy to over-design: I want to learn not to do that;
- My readers and I can see what happens when we push simplicity to and past the limit.
You’ll want to set your own balance where it works best for you. I’ve learned a lot by pushing far further toward simplicity than seems reasonable. You might want to try the same thing, to see what happens on your screen.
Anyway, here and now, I’m going to put a die
method into the system:
-- Main
function collisions()
for iObj,obj in pairs(U.Drawn) do
for iOther,other in pairs(U.Drawn) do
if colliding(obj, other) then
obj:die()
other:die()
end
end
end
end
-- Ship
function Ship:die()
U.Drawn[self] = nil
end
-- Missile
function Missile:die()
U.Drawn[self] = nil
end
function Missile:move()
self.distance = self.distance + self.vel:len()
if self.distance > self.maxDistance then
self:die()
return
end
self.pos = clip_to_screen(self.pos + self.vel)
end
function Missile:draw()
pushMatrix()
pushStyle()
strokeWidth(1)
stroke(255,255,255,255)
fill(255,255,255,255)
translate(self.pos.x, self.pos.y)
ellipse(0,0,5)
popStyle()
popMatrix()
end
Note that I changed the distance timeout for Missile to just die, and removed the alive
flag and the drawing of the big ellipse when a missile times out. Now they just die.
But explosions!!
We came here to make things explode. It’s only 10:30, so there is time to do that. Here’s my cunning plan.
When a ship dies, it will explode. It will do that by spawning an Explosion before it kills itself. The Explosion will be in charge of looking like an explosion. We’ll start with a very simple one, of course. So …
function Ship:die()
U.Drawn[self] = nil
Explosion(self)
end
-- Explosion
Explosion = class()
function Explosion:init(ship)
addDrawnAtTop(self)
self.timeOut = 100
self.pos = ship.pos
end
function Explosion:draw()
self.timeOut = self.timeOut - 1
if self.timeOut <= 0 then self:die() return end
pushMatrix()
pushStyle()
translate(self.pos:unpack())
text("Blammo!")
popStyle()
popMatrix()
end
function Explosion:die()
U.Drawn[self] = nil
end
function Explosion:move()
end
This results in a not-very-satisfying explosion that lasts about two seconds. (A little less, it appears to me.)
MOVIE HERE
Mutually assured destruction again. Now we can refine the explosion at will. However, I’m a bit concerned that one might shoot down an explosion: it doesn’t implement cannotCollide
.
function Explosion:init(ship)
addDrawnAtTop(self)
self.timeOut = 100
self.pos = ship.pos
self.cannotCollide = true
end
Sure enough, with the cannotCollide
added, the “Blammo!” stays up longer when I fire two missiles together: the second missile was shooting down the Explosion.
So: This is telling me that the cannotCollide
idea is a bit fragile: you have to remember to implement it. Maybe there is a class hierarchy trying to be born here. Perhaps the universe contains Things, some of which can collide, and some of which cannot. However, this seems to me to be a pretty small decision upon which to hang a class. We are, however, seeing other common elements. Right now, our explosion does not move. But it might want to move with the motion of the ship: the original Spacewar explosion was some fuzzy thing that moved along the ship path, as if it was fragmenting. So it is possible to be impervious to collision and yet moving.
I think it’s too soon to go to a hierarchy but it may be getting close. We’ll keep an eye out.
OK, where are we?
Well, we have improved the collision logic a bit, and objects can be told to die. We have built a place for a ship explosion to stand, although we do not have a very convincing explosion yet. But I’m not satisfied with the collision logic yet. Remember in Main:
function collisions()
for iObj,obj in pairs(U.Drawn) do
for iOther,other in pairs(U.Drawn) do
if colliding(obj, other) then
obj:die()
other:die()
end
end
end
end
The Main shouldn’t be deciding who lives or dies. We might want death to be conditioned on who’s colliding. Some examples:
- Missile vs Ship: first takes damage and dies, second takes damage
- Ship vs Missile: first takes damage, second takes damage and dies
- Missile vs Missile: both take damage and die
- Ship vs Ship: both take damage (both probably die)
- Ship or Missile vs Sun: first takes large damage, surely dies. Second not damaged.
- Sun vs Ship or Missile: First doesn’t care, second takes large damage, dies.
We have to list both combinations, because we can’t be sure which colliding object will be detected first: the pairs
function produces elements in random order. What if we did it in terms of damage? When two objects are seen to have collided, each gets sent, instead of obj:die()
, obj:doDamageTo(other)
? Then obj
might send other:takeDamage(5)
or whatever damage it can do. other
deals with that as it wishes: Missiles probably die, ships take damage until they run out of hit points, the sun shrugs it off.
The problem with this scheme, however, is that our current collision logic will discover both A colliding with B and B colliding with A: it’s looping over the Drawn collection twice. Sometimes, one collider or both will remove itself, so the second pairing will find a nil target and ignore it. But imagine two heavily armored objects that deal small damage. Meteors, perhaps, which are hard to break up. If Meteor A and Meteor B collide, they’ll each damage the other a bit … and then the same collision will be discovered again and they’ll damage each other again. We can adjust the numbers so that works, but it seems like a hack.
I think this would be easier if we only considered a pair of objects once, not twice. Usually that happens because at least one of them dies but it need not happen. What if, as we loop over the objects, we kept a table of the pairs and only considered them once, skipping them if they were already there? Something like this:
function collisions()
local considered = {}
for _, obj in pairs(U.Drawn) do
for _, other in pairs(U.Drawn) do
if considered[obj] and considered[obj][other] then
-- already done
else
if not considered[other] then considered[other] = {} end
considered[other][obj] = true
if colliding(obj, other) then
obj:die()
other:die()
end
end
end
end
end
OK, this is bloody arcane. I acknowledge that it needs a test and I promise we’ll write it. However, it works. Here’s how:
If we have processed the pair (obj, other)
we wish to avoid processing (other, obj)
later. So when we have the pair (obj, other)
in hand, we check to see if we have already processed (other, obj)
. If we have, already done. If we have not done (other, obj)
, we want to do (obj, other)
and then avoid doing (other, obj)
. So we check to see if we have done any other
s yet and if not, put an empty table into considered
for key other
. Then we put key obj
into the other
table. Later, when we try to do (other, obj)
, there it will be, and we skip it.
Yes, bloody arcane. And it works. It’s lunch time, but I’ll do the test to convince you (and myself):
function testPairs()
local items = {a = "a", b = "b", c = "c"}
_:describe("Test Skipping Pairs", function()
_:test("Find nine without considering formers", function()
local count = 0
for _, obj in pairs(items) do
for _, other in pairs(items) do
count = count + 1
end
end
_:expect(count).is(9)
end)
_:test("Find six (three choose two) with considering formers", function()
local count = 0
local considered = {}
for _, obj in pairs(items) do
for _, other in pairs(items) do
if considered[other] and considered[other][obj] then
-- skipped
else
if not considered[obj] then considered[obj] = {} end
considered[obj][other] = true
count = count + 1
end
end
end
_:expect(count).is(6)
end)
end)
end
Both these tests pass.
As it happens however, our testKill now fails, because a ship no longer dies when you fire a missile. So we fix that:
function testKill()
_:describe("Missiles Kill", function()
_:before(function()
dSave = U.Drawn
tSave = U.Touched
U.Drawn = {}
U.Touched = {}
end)
_:after(function()
U.Drawn = dSave
U.Touched = tSave
end)
_:test("All exist", function()
Ship(1)
Ship(2)
local found = {}
for _, obj in pairs(U.Drawn) do
found[obj] = obj.name
end
_:expect(found).has("Ship 1")
_:expect(found).has("Ship 2")
end)
_:test("Ship no longer kills self by firing missile", function()
local ship1 = Ship(1)
Ship(2)
Missile(ship1)
collisions()
local found = {}
for _, obj in pairs(U.Drawn) do
found[obj] = obj.name
end
_:expect(found).has("Ship 1")
_:expect(found).has("Ship 2")
end)
end)
end
That runs, and now we need an additional missile killing test that positions the missile on the other ship and calls collision again. I’ll do that tomorrow, I promise.
Summing up today
We’ve done rather a lot:
- Fire missiles out in front far enough so as not to kill the firing ship;
- Don’t kill buttons with your missiles;
- Spike ship explosion with “Blammo!”;
- Think about how to move damage closer to the objects rather than doing the decisions in Main;
- Optimize collision processing so as to process all pairs only once, preparing for the above;
And we cleaned up a little code. Not bad for three and a half hours.
See you next time!
-
“Clearly” is a word that should set off alarms in your head. Too often it signals a jump to a conclusion. So be cautious when you hear someone saying “clearly”, especially if it’s you. In this case, it turns out, I was right. Just lucky, I guess. ↩
-
I am used to working with X forward, in another world which I inhabit. So, saying Y forward, I wrote things like
vec2(U.MissileKillDistance, 0)
, and was confused when it didn’t work. It’s correct as shown above. I am almost tempted to convert this code to X forward. We’ll see. It wouldn’t be hard … ↩