Spacewar! 36 - Lets do some damage.
Tozier is joining me today, and although we have a fun thing to do in the explosion, I’m inclined to work on damage for a bit. We have plenty of reason to believe that the collision logic isn’t quite right, and possibly dealing damage will be the right way to make some progress and see how to improve the design.
I’m inclined to think that the amount of damage something does is a property of the thing. That is, a missile might do one point of damage upon striking a ship, and a ship might do five points upon striking another ship. If we had a rule like that, the game would go on for a while, since it would take five hits with missiles to take out the enemy. I’m going to suggest that we try to make that happen.
We see these issues:
- Missile does 1 point damage, ship can take 5
- Ship might display points remaining for a while after being hit
- Debris from explosions might do damage if they hit a ship.
These are all part of a “damage epic” but we’ll treat them as different stories.
We decide that in Ship:hitBy
we’ll ask the object hitting us for the amount of damage dealt and apply it:
function Ship:hitBy(anObject)
self.hitpoints = self.hitpoints - anObject.damageDealt
if self.hitpoints <= 0 then
self:die()
end
end
Both the ship and the missile need damageDealt
. We set those to 5 and 1 respectively:
Ship = class()
Ship.MaxMissiles = 50
Ship.TicksBetweenMissiles = U.MissileKillDistance + 1
Ship.ThrustAmount = vec2(0,0.02)
Ship.TurnAmount = 1
Ship.MaxSpeed = 3
Ship.MaxDamage = 5
function Ship:init(shipNumber)
self.name = string.format("Ship %d", shipNumber)
self.missileLoad = Ship.MaxMissiles
self.timeLastFired = -Ship.TicksBetweenMissiles
self.shipNumber = shipNumber
self.hitpoints = Ship.MaxDamage
self.damageDealt = 5
self.pos = self:initialPosition()
self.heading = self:initialHeading()
self.vel = vec2(0,0)
self.controls = {
left = self:controlButton(HEIGHT/5),
right = self:controlButton(2*HEIGHT/5),
thrust = self:controlButton(3*HEIGHT/5),
fire = self:controlButton(4*HEIGHT/5)
}
U:addObject(self)
end
Similarly for the missile. We’re noticing that there is a lot of commonality between these objects, which is making us think there should be some common superclasses. Soon. This code works, so we commit and push.
We decide to “bulletproof” the damage code by assuming that anything that doesn’t define damageDealt does zero:
function Ship:hitBy(anObject)
self.hitpoints = self.hitpoints - (anObject.damageDealt or 0)
if self.hitpoints <= 0 then
self:die()
end
end
We tested this. I note that if my tools were fast and convenient, I’d commit and push again. I do not, because it’s rather a big deal to do. This is troubling and gives one to think.
Missiles are currently dying for the wrong reason: because they got hit, not because they took one point. Tozier thinks this is a fine point and we might not worry about it. I agree, mostly, but we haven’t settled what happens if a Fragment gets hit by a Missile, and such. More to my interest is that if we add in this logic, we’ll increase duplication, which will increase the pressure to refactor to remove it.
function Missile:hitBy(anObject)
self.hitpoints = self.hitpoints - (anObject.damageDealt or 0)
if self.hitpoints <= 0 then
self:die()
end
end
My view is that we should test this and then refactor out the duplication. Tozier seems to wonder how we’d test whether this changes anything: how can we tell whether it’s really there? His question made me wonder how we could automate this testing.
As a stopgap, if we give missiles 2 hitpoints, then we can’t shoot one down with one shot. We’ll try that manually.
Welcome back after a Very Long Period Of Confusion. We gave each missile two hit points, but when we fired one missile at another, they both died immediately. This sent us down a long spiral of thinking our interaction loop was somehow making everyone interact twice, because we’ve had that problem in the past. After Way Too Long, Tozier realizes that the missiles are still in range of each other, and get two chances to deal damage.
This is weird. Why are we so easily confused by all this? Is it evidence that we need more automated checks? Probably. But now we’re too tired to write one. Anyway we’re convinced that this works. We’ll back out the two hit points on the Missile, and all our ridiculous debugging prints, and commit.
This sort of thing really takes the steam out of our sails. I was all ready to remove some duplication. Now my gumption has got up and went.
The next day ..
We decide that we want to remove some of the duplication in our objects, notably the flying ones, probably by building a superclass, one class to rule them all. There are probably other clever ways to provide common behavior but I’m an OO kind of guy so the superclass makes sense to me.
We did a quick digression to build a function to return the names of the functions in a class. That was amusing but not as useful as just looking. (Tozier says “no more useful”, incorrectly in my opinion.) Anyway let’s just remove some duplication. In fact, let’s not. Despite my intuitive feeling that we need some kind of container class for these guys, the code just isn’t showing it. They do share a common “interface” but they do not share all that much common code, quite possibly none at all. OK, plan B, let’s build something.
Tozier suggests gravity, which I take to mean we need to build the Sun. The Sun has two aspects, at least. First, it is visible in the center of the screen. Second, it applies acceleration to ships (and perhaps to missiles and fragments). We discuss whether the gravity might be done without the Sun but we decide to build a simple display and go from there. I am concerned with this decision because the graphical sun has almost no connection to the thing that makes gravity. But hey, maybe the Sun isn’t in the middle. So we’ll build it and then see what to do with it.
After more stupidity than I’m willing to admit, we have:
Sun = class()
function Sun:init()
self.pos = vec2(WIDTH/2, HEIGHT/2)
U:addObject(self)
end
function Sun:draw()
pushMatrix()
pushStyle()
strokeWidth(2)
translate(self.pos:unpack())
stroke(216, 168, 48, 255)
fill(216, 168, 48, 255)
ellipse(0,0,10)
popStyle()
popMatrix()
end
function Sun:move()
end
-- Main
function setup()
parameter.action("Automate", function()
automate()
end)
Sun()
Ship1 = Ship(1)
Ship(2)
end
IMAGE HERE
OK. Now gravity. Each ship needs to experience an acceleration due to gravity. Do I correctly recall that the acceleration is a constant independent of distance? What about that inverse-squared thing? I think it’s inverse squared. But Tozier points out that many of the practicalities don’t depend on the precise nature of the universe. This is profound but anyway let’s just give the ship a nudge in the direction of the sun. This will be put in Ship:move()
:
function Ship:move()
local turn
local thrust
if self.controls.left.pressed and not self.controls.right.pressed then turn = Ship.TurnAmount
elseif self.controls.right.pressed and not self.controls.left.pressed then turn = -Ship.TurnAmount
else turn = 0 end
self.heading = self.heading + turn
if self.controls.thrust.pressed then thrust = Ship.ThrustAmount else thrust = vec2(0,0) end
self.vel = self:adjustedVelocity(self.vel, self.heading, thrust)
self.pos = U:clip_to_screen(self.pos + self.vel)
if self.controls.fire.pressed then self:fireMissile() end
end
Looking at that, I want to put gravity into adjustedVelocity
:
function Ship:adjustedVelocity(vel, heading, accel)
local proposed = vel + accel:rotate(math.rad(heading))
local speed = proposed:len()
if speed > Ship.MaxSpeed then
proposed = proposed*Ship.MaxSpeed/speed
end
return proposed
end
Yes, there we go. We want to add in acceleration due to gravity there in that proposed line. Or after it …
I promised to code more by intention:
function Ship:adjustedVelocity(vel, heading, accel)
local proposed = vel + accel:rotate(math.rad(heading))
proposed = proposed + self:gravity()
local speed = proposed:len()
if speed > Ship.MaxSpeed then
proposed = proposed*Ship.MaxSpeed/speed
end
return proposed
end
See? self:gravity()
! This is practically solved now.
function Ship:gravity()
local directionToSun = (U.sun.pos - self.pos):normalize()
local distSquared = self.pos:distSqr(U.sun.pos)
return U.GravitationalConstant*directionToSun/distSquared
end
-- Universe
function Universe:init(x)
self.Adds = {}
self.Drawn = {}
self.Removes = {}
self.Touched = {}
self.MissileKillDistance = 20
self.GravitationalConstant = 100
self.Tick = 0
end
And it looks fairly good:
MOVIE HERE
Time to push code. And done.
Tozier thinks gravity should affect missiles, or at least fragments. I’m OK with that, and it will lead to more duplication, which will increase pressure to remove duplication. OK, fragments first.
With a bit of copy and paste (another danger signal) we get to this point:
function Fragment:adjustedVelocity(vel)
proposedV = vel + self:gravity()
return proposedV
end
And we need a self:gravity()
. Let’s at least centralize that … in the Universe! Tozier wants to make it work. Well, we have the implementation hat on so we shouldn’t refactor. I agree even though I’d rather leap into space assuming that a branch will appear out there somewhere. So first, Fragment gravity.
function Fragment:gravity()
local directionToSun = (U.sun.pos - self.pos):normalize()
local distSquared = self.pos:distSqr(U.sun.pos)
return U.GravitationalConstant*directionToSun/distSquared
end
We left the maximum speed out of Fragment. That wasn’t a great idea:
MOVIE HERE
The gravity function is duplicated. Does this call for a common superclass? I think not in this case. Instead, I suggest that gravity is a property of the universe, not of a ship or a fragment or a common superclass. Thus:
function Universe:gravity(anObject)
local directionToSun = (U.sun.pos - anObject.pos):normalize()
local distSquared = anObject.pos:distSqr(U.sun.pos)
return U.GravitationalConstant*directionToSun/distSquared
end
function Fragment:adjustedVelocity(vel)
proposedV = vel + U:gravity(self)
return proposedV
end
This should work … and it does. Now we change Ship:
function Ship:adjustedVelocity(vel, heading, accel)
local proposed = vel + accel:rotate(math.rad(heading))
proposed = proposed + U:gravity(self)
local speed = proposed:len()
if speed > Ship.MaxSpeed then
proposed = proposed*Ship.MaxSpeed/speed
end
return proposed
end
This, too, should work … and it does. Tozier insists on doing maximum velocity, having talked me out of it earlier. “But here’s the thing”, he says … meaning, after some interrogation, where does it go? I answer that it is a property of the universe. We think this in perfect unison.
We start with more copy and paste:
function Fragment:adjustedVelocity(vel)
proposedV = vel + U:gravity(self)
local speed = proposedV:len()
if speed > Ship.MaxSpeed then
proposedV = proposedV*Ship.MaxSpeed/speed
end
return proposedV
end
This is unfortunate: the fragments, if subject to gravity, just stick to the sun when we crash into it:
MOVIE HERE
We fiddle a bit with the maximum speed of a fragment, to get this:
function Fragment:adjustedVelocity(vel)
local max = 8
proposedV = vel + U:gravity(self)
local speed = proposedV:len()
if speed >max then
proposedV = proposedV*max/speed
end
return proposedV
end
MOVIE HERE
So … that’s not bad but it is weird. Ships flying that fast would surely be too fast to control. Fragments limited to ship speed don’t deal well with hitting the sun. And the basic form of the function is the same. We can move the function to universe and call back to the object for its maximum speed. At least that would centralize more of the code.
function Fragment:init(fragmentNumber, ship)
self.type = fragmentNumber
self.cannotCollide = true
self.pos = ship.pos
local speed = 1 + math.random()
local outwardVelocity = vec2(0,speed):rotate(math.random()*math.pi*2)
self.vel = ship.vel + outwardVelocity
self.angularVelocity = math.random()*10-5
self.heading = 0
self.speedLimit = 8
self.timeOut = math.random(200,300)
U:addObject(self)
end
function Fragment:adjustedVelocity(vel)
proposedV = vel + U:gravity(self)
return U:limitSpeed(self, proposedV)
end
function Universe:limitSpeed(anObject, vel)
local speed = vel:len()
local max = anObject.speedLimit
if speed > max then
vel = vel*max/speed
end
return vel
end
This works fine. Let’s fix the ship to do the same … in doing so, we note that Ship has a “class variable” for its speed limit, so we change Fragment to work the same way, with this result:
function Ship:adjustedVelocity(vel, heading, accel)
local proposed = vel + accel:rotate(math.rad(heading))
proposed = proposed + U:gravity(self)
return U:limitSpeed(self, proposed)
end
function Fragment:adjustedVelocity(vel)
proposedV = vel + U:gravity(self)
return U:limitSpeed(self, proposedV)
end
function Universe:limitSpeed(anObject, vel)
local speed = vel:len()
local max = anObject.MaxSpeed
if speed > max then
vel = vel*max/speed
end
return vel
end
And in Fragment we add the “class variable:
Fragment = class()
Fragment.MaxSpeed = 8
Fragment.types = {}
...
And with that, we declare ourselves done for the day and push the code.
Now for a brief Ruh-roh-spective.
We were confused at the beginning, because we forgot that the Universe has to create a sun before there is one. So we kept trying to make it more visible when it wasn’t being drawn at all. We can’t think of a clean way to ensure that this happens. Of course if we had written a test it would have required us to create one. That might have helped. Or if we had really started by intention, we’d have started in the Universe, creating a non-existent Sun instance, driving creation of the tab … let’s try to remember to start in the Universe in future. I am not planning to be any smarter tomorrow, so this may not work.
There still seem to us to be lots of similar pieces of code lying about. It bothers T that we pass to the speed limit code both the object and its proposed velocity. “Would it be better if we set the proposed into the velocity and then limited it by having the universe hammer it?” “Would it be better to pass the maximum instead of ripping it out?” Ah, yes. Law of Demeter. We probably should pass the maximum into this common universal function. Make a note of that.
We didn’t get in much trouble today … but we didn’t change our practice much from prior days. Were we just lucky?
Tozier questions whether a distance-squared gravity is ideal. I think he wants more gravity the further away you get or something. Maybe negative gravity? At one point he suggested gravity pulling straight down the screen, so the ships tend to pile up at the bottom. Of course, since the universe is a torus maybe a sun in the corner. No, stop. Just stop.
We don’t think we’ve learned a lot today: we just got some nice work done.
See you next time …