Spacewar 38 - Some play and improvements.
Tozier and I began by playing the game a bit. In the course of that we identified several things we’d like to change:
- Thrust should be visible on screen
- Hard to tell the ships apart
- The sun is just a dot and should be more interesting
- Game should restart when someone dies
- No rapid fire of bullets because the ship’s bullets kill each other
- Ship rotation is too slow for easy steering
- Codea start/pause controls should be offscreen during play
- But we do need to take pictures and videos
- Ship max speed is probably too low
- Gravity is probably too high
- Thrust is probably too low
- Should bullets be affected by gravity or not?
- We have hit points but no display of damage status on a hit
Looking ahead, we think the game should have a “control panel” of parameters that can be adjusted before the game. Then you’d push “Run”, the panel would go away, and the game would commence. The Codea controls would hide at that time. Some trick, like touching the Sun, would bring the Codea controls back so you can restart the game, start a video, whatever.
We did a Spike. Well, actually, we coded something and declared it a Spike after a while. Note to self: whenever coding something, say at the beginning “Let’s try a Spike”, so you won’t be embarrassed later about throwing it away. (No, seriously: one should never be embarrassed to throw away something that drifts off in the wrong direction.)
What did we learn from our Spike? We built a “Run” button and tried a couple of ways of making it work. We gave the Universe a “Running” state and, on the second try, made it clear the screen in a different place so that the screen is black when not running. Along the way we learned that Codea has two display buffers that it switches between. We had an interesting effect, which was that Codea does not clear those buffers before giving them to you, so when the game stops running, the last two frames swap back and forth with an interesting flicker effect. So we moved a few things around in the draw logic until we got a black screen when not running.
We found that ownership of universe creation and ownership of the screen is a bit entangled. We tried a button to change the Ship’s maximum speed, which we did by setting the existing Ship class variable to a different value. This (it seems obvious now) affects any new ships, if the Universe and Ships are created anew, but does not affect Ships that already exist. Since Main was building the universe and installing the ships, this surprised us a bit. The creation of the Universe may need to be put into the Universe.
So as we go forward we want to be alert to the ownership of system state and see if we want to improve it. For example, when ships apply thrust, they apply Ship.ThrustAmount. But when they check maximum velocity self.MaxSpeed is used. But that’s the MaxSpeed copied into the ship instance from the class, not the class variable. So something is inconsistent there.
All these are things we’ll take into account now that our Spike is over.
One more thing: since we changed the buttons, our button tests are not running. We didn’t even look at this but we should do so and decide what to do about it.
An interesting day. We reverted the code to what it was before we started. Yay, Codea Source Code Management and yay, Github.
A New Day Dawns
Just to loosen up, I add a Running button to the game:
parameter.boolean("Running", false)
And to make it work, in Universe:
function Universe:draw()
U.Tick = U.Tick + 1
self:updateUniverse()
if Running then
self:moveAll()
self:interactAll()
end
self:drawAll()
end
It took two tries to get this just right: we need to updateUniverse
to pick up any added objects, like the sun and ships, but not to move them about or let them explode if they haven’t already done so. The result looks like this:
IMAGE HERE
We decide, somehow, to work with the screen layout. We want three states:
- When the game starts, the Codea controls are visible and the game is not running;
- When the game is set to running, the Codea controls, and the run/restart controls all go away;
- When the sun is touched, we go back to showing the controls.
Research tells us that we want the screen settings to be:
- STANDARD
- FULLSCREEN_NO_BUTTONS
- STANDARD
Our first attempt at this teaches us a lesson. We add this to our parameter:
parameter.boolean("Running", false, function()
if Running then displayMode(FULLSCREEN) end
end)
Which does switch us to the full screen mode but doesn’t relocate the high-end buttons:
IMAGE HERE
It seems to me that this is because the buttons’ positions are computed when they are built, and need to be computed more dynamically. At a second glance we note that the ships’ positions, and the Sun’s position are also not computed. One possibility is to calculate all those offsets dynamically. Another is to restart the game with the new HEIGHT and WIDTH. I am inclined toward the latter. This will mean that our Running button starts the game over but I think we can live with that. We could have a separate pause button someday if we need it.
Our next effort, shown above, takes a few tries and I don’t like everything about what we found. We knew that we needed to touch the sun (as one does) to get the screen back. We discovered, if we didn’t know it, that we want to restart the program at that point. So in Sun we added this touch:
function Sun:touched(touch)
if touch.state == BEGAN and self:nearMe(touch) then
displayMode(STANDARD)
setup()
end
end
function Sun:nearMe(touch)
return self.pos:dist(vec2(touch.x,touch.y)) < 50
end
This worked, after we remembered that Sun had to register as touchable. However, calling setup() didn’t really stop the game, because the Universe is created outside setup, basically at compile time. We moved creation inside setup, only to realize that the Universe is relied upon by other classes, who also do things at compile time. Meh. Our “fix” was to create a new universe inside setup, leaving the compile-time creation there as well:
function setup()
U = Universe()
displayMode(STANDARD)
parameter.action("Automate", function()
automate()
end)
parameter.action("Running", function()
displayMode(FULLSCREEN_NO_BUTTONS)
newGame()
end)
end
But in talking about it with you, we realize that initializing the universe should suffice: we don’t need to make a new one:
function setup()
U:init()
displayMode(STANDARD)
parameter.action("Automate", function()
automate()
end)
parameter.action("Running", function()
displayMode(FULLSCREEN_NO_BUTTONS)
newGame()
end)
end
We can’t show you a movie of this, because Codea won’t take a movie of the buttons appearing and disappearing but it works as intended. Time to push the code.
We also noticed that the ships seem to be controlled by the wrong buttons. That is, I expect that my ship should be the one that faces away from my buttons at startup. The reverse is true now. Presumably we can change the button setup to reverse who gets which buttons:
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(85, HEIGHT-50, 85, 50),
right = self:controlButton(50, HEIGHT-185, 50, 85),
thrust = self:controlButton(50, 185, 50, 85),
fire = self:controlButton(85, 50, 85, 50)
}
U:addObject(self)
end
function Ship:controlButton(x,y, w2, h2)
if self.shipNumber == 1 then
return Button(x,y, w2, h2)
else
return Button(WIDTH-x,HEIGHT-y, w2, h2)
end
end
I’m guessing changing that to say if self.shipNumber == 2
will fix this issue. And in fact that works. Now we are in a position to do lots of the things on our list. We push the code again, to continue another day. Here’s where we are now:
--# Fragment
Fragment = class()
Fragment.MaxSpeed = 8
Fragment.types = {}
Fragment.types[1] = function()
line(0,7,-3,-8)
line(-3,-8, 1, -4)
line(1,-4, 0,7)
end
Fragment.types[2] = function()
line(-2,2, 3,6)
line(3,6, 3,-2)
line(3,-2, -4,-4)
line(-4,-4, -2,2)
end
Fragment.types[3] = function()
line(-4,1, 3,3)
line(3,3, 3,1)
line(3,1, -1,-3)
line(-1,-3, -5,-3)
line(-5,-3, -4,1)
end
Fragment.types[4] = function()
line(-5,2, -1,2)
line(-1,2, 3,6)
line(3,6, 6,-2)
line(6,-2, -6,-2)
line(-6,-2, -5,2)
end
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.timeOut = math.random(200,300)
U:addObject(self)
end
function Fragment:move()
self.vel = self:adjustedVelocity(self.vel)
self.pos = U:clip_to_screen(self.pos + self.vel)
self.heading = self.heading + self.angularVelocity
end
function Fragment:adjustedVelocity(vel)
proposedV = vel + U:gravity(self)
return U:limitSpeed(self, proposedV)
end
function Fragment:draw()
self.timeOut = self.timeOut - 1
if self.timeOut < 0 then self:die() end
pushMatrix()
pushStyle()
translate(self.pos:unpack())
rotate(self.heading)
self:drawFragment(self.type)
popStyle()
popMatrix()
end
function Fragment:drawFragment(type)
strokeWidth(2)
if self.types[type] then self.types[type]() else
pushStyle()
fill(255)
text("?",0,0)
popStyle()
end
end
function Fragment:hitBy(anObject)
self:die()
end
function Fragment:die()
U:kill(self)
end
--# Sun
Sun = class()
function Sun:init()
self.pos = vec2(WIDTH/2, HEIGHT/2)
self.name = "Sun"
self.damageDealt = 100
U:addObject(self)
U:addTouched(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
function Sun:hitBy(anObject)
end
function Sun:touched(touch)
if touch.state == BEGAN and self:nearMe(touch) then
displayMode(STANDARD)
setup()
end
end
function Sun:nearMe(touch)
return self.pos:dist(vec2(touch.x,touch.y)) < 50
end
--# Universe
-- HAVE YOU PUSHED TO GITHUB TODAY?
Universe = class()
function Universe:init()
self.Adds = {}
self.Drawn = {}
self.Removes = {}
self.Touched = {}
self.MissileKillDistance = 20
self.GravitationalConstant = 200
self.Tick = 0
end
function Universe:draw()
U.Tick = U.Tick + 1
self:updateUniverse()
self:moveAll()
self:interactAll()
self:drawAll()
end
function Universe:touched(touch)
for _,obj in ipairs(self.Touched) do
obj:touched(touch)
end
end
-- helper functions
function Universe:addInNewObjects()
for _, obj in ipairs(self.Adds) do
self.Drawn[obj] = obj
end
self.Adds = {}
end
function Universe:addObject(anObject)
table.insert(self.Adds, anObject)
end
function Universe:addTouched(anObject)
table.insert(self.Touched, anObject)
end
function Universe:clip_to_screen(vec)
return vec2(vec.x%WIDTH, vec.y%HEIGHT)
end
function Universe: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
function Universe:drawAll()
background(40, 40, 50)
for _, obj in pairs(U.Drawn) do
obj:draw()
end
end
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 Universe:interactAll()
for _, a in pairs(U.Drawn) do
for _, b in pairs(U.Drawn) do
if U:colliding(a,b) then
a:hitBy(b)
end
end
end
end
function Universe:kill(anObject)
table.insert(self.Removes, anObject)
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
function Universe:moveAll()
for _, obj in pairs(U.Drawn) do
obj:move()
end
end
function Universe:removeDeadObjects()
for _, obj in ipairs(self.Removes) do
self.Drawn[obj] = nil
end
self.Removes = {}
end
function Universe:updateUniverse()
self:removeDeadObjects()
self:addInNewObjects()
end
--# Button
-- HAVE YOU PUSHED TO GITHUB TODAY?
Button = class()
function Button:init(x, y, w2, h2)
self.name = string.format("Button %f %f", x, y)
self.pos = vec2(x,y)
self.width = w2
self.height = h2
self.pressed = false
self.capturedID = nil
self.cannotCollide = true
U:addTouched(self)
U:addObject(self)
end
function Button:draw()
pushStyle()
pushMatrix()
rectMode(RADIUS)
translate(self.pos:unpack())
strokeWidth(1)
stroke(255)
fill(self:fillColor())
rect(0,0, self.width, self.height)
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:insideMe(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:insideMe(touch)
end
end
function Button:insideMe(touch)
local x = touch.x
local y = touch.y
local myLeft = self.pos.x - self.width
local myRight = self.pos.x + self.width
local myBottom = self.pos.y - self.height
local myTop = self.pos.y + self.height
local insideX = x >= myLeft and x <= myRight
local insideY = y >= myBottom and y <= myTop
return insideX and insideY
end
function Button:move()
-- don't
end
--# Main
-- HAVE YOU PUSHED TO GITHUB TODAY?
-- S3 Spacewar
-- The Universe
U = Universe()
function setup()
U:init()
displayMode(STANDARD)
parameter.action("Automate", function()
automate()
end)
parameter.action("Running", function()
displayMode(FULLSCREEN_NO_BUTTONS)
newGame()
end)
end
function automate()
Missile(Ship1, vec2(0,10))
Ship1.heading = Ship1.heading + 90
Missile(Ship1, vec2(0,10))
end
function draw()
U:draw()
end
function touched(touchid)
U:touched(touchid)
end
function newGame()
U.sun = Sun()
Ship1 = Ship(1)
Ship(2)
end
--[[
function allFunctions(aClass)
names = {}
for k,v in pairs(aClass) do
print(k,v)
if type(v) == "function" then table.insert(names,k) end
end
table.sort(names)
ans = ""
for i,n in pairs(names) do
ans = ans.."\n"..n
end
print(ans)
end
]]--
--# Missile
-- HAVE YOU PUSHED TO GITHUB TODAY?
Missile = class()
Missile.count = 0
function Missile:init(ship, testVel, startPos)
local myVel = testVel or vec2(0,1)
self.name = "missile " .. Missile.count
self.damageDealt = 1
self.hitpoints = 1
Missile.count = Missile.count + 1
self.pos = ship.pos + (startPos or self:aWaysOutInFront(ship))
local velocity = myVel:rotate(math.rad(ship.heading))
self.vel = ship.vel + velocity
self.maxDistance = WIDTH*1.4
self.distance = 0
U:addObject(self)
end
function Missile:move()
self.distance = self.distance + self.vel:len()
if self.distance > self.maxDistance then
self:die()
return
end
self.pos = U: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:hitBy(anObject)
self.hitpoints = self.hitpoints - (anObject.damageDealt or 0)
if self.hitpoints <= 0 then
self:die()
end
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
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(85, HEIGHT-50, 85, 50),
right = self:controlButton(50, HEIGHT-185, 50, 85),
thrust = self:controlButton(50, 185, 50, 85),
fire = self:controlButton(85, 50, 85, 50)
}
U:addObject(self)
end
function Ship:controlButton(x,y, w2, h2)
if self.shipNumber == 2 then
return Button(x,y, w2, h2)
else
return Button(WIDTH-x,HEIGHT-y, w2, h2)
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 = U: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))
proposed = proposed + U:gravity(self)
return U:limitSpeed(self, proposed)
end
function Ship:hitBy(anObject)
self.hitpoints = self.hitpoints - (anObject.damageDealt or 0)
if self.hitpoints <= 0 then
self:die()
end
end
function Ship:die()
self:explode()
U:kill(self)
end
function Ship:explode()
for i = 1, 5 do
Fragment(i, self)
end
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:insideMe(insideEndTouch)).is(true)
_:expect(button:insideMe(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)
U:updateUniverse()
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)
U:updateUniverse()
U:interactAll()
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