Spacewar! 29 - Let there be stuff!
Let’s create … THE UNIVERSE!
Today, as a result of a dream or something, I’ve decided that it’s time for the global object U
, which contains the Drawn
and Touched
collections, and a few functions, to become a real class-based object. I plan to call the class Universe, and its instance will still be named U. My reasons for doing this are mostly about consistency: since other entities like Button
, Ship
, and Missile
are objects, everything should be that way. In addition, I want to hide a bit more information, like the existence of the Drawn/Touched distinction, and I have in mind making it easier to run the tests independently of the game. (You may recall that the tests have a tendency to throw ships and buttons and things onto the screen, and there’s some saving and restoring going on in the tests that we can probably do better inside a Universe object.
Here’s what we have in U right now:
-- Main
-- The Universe
U = {}
U.Tick = 0
U.Adds ={}
U.Drawn = {}
U.Removes = {}
U.Touched = {}
U.Running = true
U.MissileKillDistance = 20
U.kill = function(objToRemove)
table.insert(U.Removes, objToRemove)
end
U.add = function(objToAdd)
table.insert(U.Adds, objToAdd)
end
I think it was the dot-functions, kill
and add
that really pushed me toward an object. Most functions in our game are called with colon, because they are methods on classes. That these two are different bugs me, and it is surely defect-prone, which bugs the program.1
I expect this to be a pretty simple refactoring, just add the class much like the setup code above, and then hook it up. Here goes:
More than an hour later …
The refactoring was a bit larger than I thought: I had to change more U.thisAndThat to U:thisAndThat, and the like. But it seemed to go OK until I ran the program. When I ran the program and fired a missile across the screen at myself, the Explosion appeared, but the Ship did not disappear, and the Missile did not disappear. Finally the Missile timed out, but it still didn’t disappear, it just sat there.
Then I did the bad thing: I decided to debug the problem. And, in fact, I did debug the problem, just now: somehow I was adding Drawn items in the old ipairs
style, not the new pairs
style. I’m not sure if that old code was there or if I typed it in wrong. Either way, a one line fix and it seems to be working.
But when we undertake a refactoring and have to hammer it to work, it is a sign from nature that we’ve taken too big a bite. The more nearly right thing to do when a refactoring doesn’t work is to back it out and do it again. And, very likely, we should do it in smaller steps. So that’s what I’m going to do. I’ve rolled back to my starting point, and I’m going to convert to a Universe class in smaller steps.
That’s going to be a bit tricky. I’ll build the class and move functionality over to it bit by bit. I think the right thing will be to create the Universe instance, make it known to the current U table, forward messages to it until it’s doing all the work, then remove the U table. The trickiest bit will be that U has some values that are directly accessed. Probably the best thing will be to give those accessors in Universe and use those. If we do that one at a time, we’ll write U:tick() where we used to have U.Tick and the U table will forward its tick() message to the Universe instance. We’ll have to fudge with the dot vs colon thing and that may be bad enough for this plan to be no good. We’ll try to choose things to refactor so as to find trouble early, in case the plan needs to be updated.
Here goes … again:
Universe = class()
function Universe:init(x)
end
function Universe:draw()
-- Codea does not automatically call this method
end
function Universe:touched(touch)
-- Codea does not automatically call this method
end
-- Main
-- The Universe
U = {}
U.universe = Universe()
U.Tick = 0
U.Adds ={}
U.Drawn = {}
U.Removes = {}
U.Touched = {}
U.Running = true
U.MissileKillDistance = 20
U.kill = function(objToRemove)
table.insert(U.Removes, objToRemove)
end
U.add = function(objToAdd)
table.insert(U.Adds, objToAdd)
end
All I’ve done here is create an empty Universe class, and instantiate one into U.universe. This, of course, runs fine. Well, almost. Because the Codea tabs are compiled in order from left to right, I have to drag the Universe class to the left, ahead of Main. Once I do that, it’s all good.
I’m really tempted to move drawing to Universe. Right now, it’s in Main:
function draw()
U.Tick = U.Tick + 1
remove()
add()
moveAll()
interactAll()
drawAll()
end
With all those little functions also inside Main, of course. However, they are global, so we should be able to move just this one function to Universe, and call it from Main. It will call back and everything should continue to work. Let’s try:
Universe = class()
function Universe:init(x)
end
function Universe:draw()
U.Tick = U.Tick + 1
remove()
add()
moveAll()
interactAll()
drawAll()
end
function Universe:touched(touch)
-- Codea does not automatically call this method
end
-- Main
function draw()
U.universe:draw()
end
Works as intended. Of course it’s calling right back so no surprise. However, each change so far has taken only seconds and the system still works. That’s more like “Agile” as I understand it.
Note, however, that I’m testing by running the game. That tells me that my tests are not robust enough to give me confidence if I just run them. We’ll put that on the list for later but if we get in trouble again we’d better think about better tests. The tests do all run, however. One of them is still putting a spurious Button on the screen, because it doesn’t have that save/restore in it, but I plan to leave that until the Universe is created.
What next? For the drawing functions to run, we need the U.Drawn table to be supported by Universe. I can think of a few ways to get that happening:
- maintain it for now in U, and jam a copy into U.universe
- push it now to U.universe and refactor the U functions to call over there
- push the touch logic over first, because it will be easier.
I’m nervous because of the previous screwup, so moving touch seems like I might learn something. My “hold my beer” personality component wants to go ahead with the harder thing but I think I’ll stay conservative for a bit longer:
-- Universe
function Universe:touched(touch)
for _,obj in ipairs(U.Touched) do
obj:touched(touch)
end
end
-- Main
function touched(touchid)
U.universe:touched(touchid)
end
This works as intended. Of course, the Touch table isn’t moved over yet. That requires us to initialize it in Universe, and to forward the addTouched function from Main. Currently we have:
--Main
function addTouched(anObject)
table.insert(U.Touched, anObject)
end
Which we’ll replace with:
function addTouched(anObject)
U.universe:addTouched(anObject)
end
And in Universe:
Universe = class()
function Universe:init(x)
self.Touched = {}
end
function Universe:draw()
U.Tick = U.Tick + 1
remove()
add()
moveAll()
interactAll()
drawAll()
end
function Universe:touched(touch)
for _,obj in ipairs(self.Touched) do
obj:touched(touch)
end
end
function Universe:addTouched(anObject)
table.insert(self.Touched, anObject)
end
So the process was:
- Forward Main’s
touched
to U.universe, as a method call, colon not dot. - In Universe
init
, add tableTouched
- Copy over Main’s functions
touched
andaddTouched
I managed to screw this up, however, because I really don’t like the capitalized name Touch in Universe, so I changed it, and did so incorrectly. One thing at a time. We’ll make a note to deal with the names later.
It’s close to lunch time (Chet is here and you know how he is), but I am very tempted to start moving the Drawn
table over as well. I’d like not to run out of time, so I’ll break for lunch with C het. Maybe I’ll come back today. More likely, tomorrow.
Anyway we have a running version with a start at Universe, and no weird bugs. This is better so far.
--# Universe
Universe = class()
function Universe:init(x)
self.Touched = {}
end
function Universe:draw()
U.Tick = U.Tick + 1
remove()
add()
moveAll()
interactAll()
drawAll()
end
function Universe:touched(touch)
for _,obj in ipairs(self.Touched) do
obj:touched(touch)
end
end
function Universe:addTouched(anObject)
table.insert(self.Touched, anObject)
end
--# Button
-- HAVE YOU PUSHED TO GITHUB TODAY?
Button = class()
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)
U.add(self)
end
function Button:draw()
pushStyle()
pushMatrix()
ellipseMode(RADIUS)
translate(self.pos:unpack())
fill(self:fillColor())
ellipse(0,0, self.radius)
popMatrix()
popStyle()
end
function Button:fillColor()
if (self.pressed) then
return color(0, 255, 0, 255)
else
return color(255,0,0,26)
end
end
function Button:touched(touch)
self.capturedID = self:getCapture(touch)
self.pressed = self:getPressed(touch)
end
function Button:getCapture(touch)
if touch.state == BEGAN and self:insideCircle(touch) and not self.capturedID then
return touch.id
elseif touch.state == ENDED and self.capturedID == touch.id then
return nil
else
return self.capturedID
end
end
function Button:getPressed(touch)
if not self.capturedID then
return false
elseif self.capturedID ~= touch.id then
return self.pressed
else
return self:insideCircle(touch)
end
end
function Button:insideCircle(touch)
return self.pos:dist(vec2(touch.x, touch.y)) <= self.radius
end
function Button:move()
-- don't
end
--# Explosion
-- HAVE YOU PUSHED TO GITHUB TODAY?
Explosion = class()
function Explosion:init(ship)
U.add(self)
self.timeOut = 100
self.pos = ship.pos
self.cannotCollide = true
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.kill(self)
end
function Explosion:move()
end
--# Main
-- HAVE YOU PUSHED TO GITHUB TODAY?
-- S3 Spacewar
-- The Universe
U = {}
U.universe = Universe()
U.Tick = 0
U.Adds ={}
U.Drawn = {}
U.Removes = {}
U.Touched = {}
U.Running = true
U.MissileKillDistance = 20
U.kill = function(objToRemove)
table.insert(U.Removes, objToRemove)
end
U.add = function(objToAdd)
table.insert(U.Adds, objToAdd)
end
function setup()
Ship(1)
Ship(2)
U.Running = true
end
function touched(touchid)
U.universe:touched(touchid)
end
function draw()
U.universe:draw()
end
function remove()
for _, obj in ipairs(U.Removes) do
U.Drawn[obj] = nil
end
U.Removes = {}
end
function add()
for _, obj in ipairs(U.Adds) do
U.Drawn[obj] = obj
end
U.Adds = {}
end
function moveAll()
for _, obj in pairs(U.Drawn) do
obj:move()
end
end
function interactAll()
for _, a in pairs(U.Drawn) do
for _, b in pairs(U.Drawn) do
if colliding(a,b) then
a:die()
b:die()
end
end
end
end
function drawAll()
background(40, 40, 50)
for _, obj in pairs(U.Drawn) do
obj:draw()
end
end
function addTouched(anObject)
U.universe:addTouched(anObject)
end
function clip_to_screen(vec)
return vec2(vec.x%WIDTH, vec.y%HEIGHT)
end
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
local d = obj1.pos:dist(obj2.pos)
local result = d < U.MissileKillDistance
return result
end
--# Missile
-- HAVE YOU PUSHED TO GITHUB TODAY?
Missile = class()
Missile.count = 0
function Missile:init(ship)
self.name = "missile" .. Missile.count
Missile.count = Missile.count + 1
self.pos = ship.pos + self: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
U.add(self)
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
function Missile:aWaysOutInFront(ship)
local aWays = vec2(0, U.MissileKillDistance*1.5)
return aWays:rotate(math.rad(ship.heading))
end
function Missile:die()
U.kill(self)
end
--# Ship
-- HAVE YOU PUSHED TO GITHUB TODAY?
Ship = class()
Ship.MaxMissiles = 50
Ship.TicksBetweenMissiles = U.MissileKillDistance + 1
Ship.ThrustAmount = vec2(0,0.02)
Ship.TurnAmount = 1
Ship.MaxSpeed = 3
-- do more of this
function Ship:init(shipNumber)
self.name = string.format("Ship %d", shipNumber)
self.missileLoad = Ship.MaxMissiles
self.timeLastFired = -Ship.TicksBetweenMissiles
self.shipNumber = shipNumber
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.add(self)
end
function Ship:controlButton(offset)
if self.shipNumber == 1 then
return Button(WIDTH-85, offset)
else
return Button(85, HEIGHT-offset)
end
end
function Ship:initialPosition()
if self.shipNumber == 1 then
return vec2(WIDTH/2, HEIGHT/2 + 200)
else
return vec2(WIDTH/2, HEIGHT/2 - 200)
end
end
function Ship:initialHeading()
if self.shipNumber == 1 then
return 90
else
return 270
end
end
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 = clip_to_screen(self.pos + self.vel)
if self.controls.fire.pressed then self:fireMissile() end
end
function Ship:fireMissile()
if self.missileLoad > 0 and U.Tick - self.timeLastFired >= Ship.TicksBetweenMissiles then
self.timeLastFired = U.Tick
self.missileLoad = self.missileLoad - 1
Missile(self)
end
end
function Ship:draw()
pushMatrix()
pushStyle()
strokeWidth(2)
translate(self.pos:unpack())
rotate(self.heading)
line(0, 15, 5, -15)
line(5, -15, -5, -15)
line(-5, -15, 0, 15)
popStyle()
popMatrix()
end
function Ship:adjustedHeading(heading, turn)
return heading + turn
end
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
function Ship:die()
U.kill(self)
Explosion(self)
end
--# TestButtonCU
-- HAVE YOU PUSHED TO GITHUB TODAY?
function testSpacewarButton()
CodeaUnit.detailed = false
local dSave
local tSave
local insideEndTouch = { id=1, state=ENDED, x=100, y=100 }
local insideBeganTouch = { id=3, state=BEGAN, x=100, y=100 }
local outsideBeganTouch = { id=4, state=BEGAN, x=151, y=100 }
local closeThree = { id=3, state=ENDED, x=200, y=200 }
local movingFinger = { id=5, state=BEGAN, x = 100, y=100}
local button
_:describe("Spacewar Button Test", function()
_:before(function()
dSave = U.Drawn
tSave = U.Touched
U.Drawn = {}
U.Touched = {}
button = Button(100, 100)
end)
_:after(function()
U.Drawn = dSave
U.Touched = tSave
end)
_:test("No global button", function()
_:expect(_G["button"]).is(nil)
end)
_:test("Inside Circle", function()
_:expect(button:insideCircle(insideEndTouch)).is(true)
_:expect(button:insideCircle(outsideBeganTouch)).is(false)
end)
_:test("Initial Button Capture", function()
button:touched(insideBeganTouch)
_:expect(button.capturedID).is(3)
button:touched(outsideBeganTouch)
_:expect(button.capturedID).is(3)
_:expect(button.pressed).is(true)
end)
_:test("Sequence Test", function()
button:touched(closeThree)
_:expect(button.capturedID).is(nil)
button:touched(movingFinger)
_:expect(button.capturedID).is(5)
_:expect(button.pressed).is(true)
movingFinger.state=MOVING
button:touched(movingFinger)
_:expect(button.pressed).is(true)
movingFinger.x = 200
button:touched(movingFinger)
_:expect(button.pressed).is(false) -- no longer pressed
_:expect(button.capturedID).is(5) -- but retains capture
end)
_:test("Two Fingers", function()
button:touched(insideBeganTouch)
_:expect(button.capturedID).is(3)
_:expect(button.pressed).is(true)
button:touched(outsideBeganTouch)
_:expect(button.capturedID).is(3)
_:expect(button.pressed).is(true)
end)
end)
end
--# TestControlPlacement
-- HAVE YOU PUSHED TO GITHUB TODAY?
function testControlPlacement()
local dSave
local tSave
_:describe("Control Placement", function()
_:before(function()
dSave = U.Drawn
tSave = U.Touched
U.Drawn = {}
U.Touched = {}
end)
_:after(function()
U.Drawn = dSave
U.Touched = tSave
end)
_:test("hookup", function()
_:expect("hookup").is("hookup")
end)
_:test("right ship buttons", function()
local rs = Ship(1)
-- _:expect(rs.controls.left.pos.y).is(200)
_:expect(rs.controls.left.pos.x).is(WIDTH - 85)
end)
_:test("left ship buttons", function()
local ls = Ship(2)
-- _:expect(ls.controls.left.pos.y).is(HEIGHT-200)
_:expect(ls.controls.left.pos.x).is(85)
end)
end)
end
--# TestKill
-- HAVE YOU PUSHED TO GITHUB TODAY?
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
--# TestPairs
-- HAVE YOU PUSHED TO GITHUB TODAY?
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
-
See what I did there? ↩