Spacewar 39 - Tuning some parameters.
Some parameters
Here are some parameters we’d like to tune:
- Ship rotation is too slow for easy steering
- Ship max speed is probably too low
- Gravity is probably too high
- Thrust is probably too low
- No rapid fire of bullets because the ship’s bullets kill each other
One possible question is whether there are some we’ll set and forget, removing their controls. For now, let’s assume we’ll leave them all, so there is no particular reason to solve one before another.
Tozier raises the issue of whether and how we might save preferred parameters (and sets) from game to game. Research …
It turns out that Codea has a number of levels of persistent data storage, local, project, and global. Local belongs to this instance of this program: Spacewar on my machine. Project belongs to the project: Spacewar on all our machines. Global belongs to a given machine: all project on my machine. There is also the ability to write any text to a project tab in the editor, and there are JSON encode and decode functions.
The local, project, and global storage amount to key-value pairs. We figure we’ll start with putting things in local storage.
Looking further ahead than we have a right to, I imagine that given code like this:
function Universe:init()
self.Adds = {}
self.Drawn = {}
self.Removes = {}
self.Touched = {}
self.MissileKillDistance = 20
self.GravitationalConstant = 200
self.Tick = 0
end
We might see the setting of MissileKillDistance
first checking local storage and using that value if any, otherwise the coded-in default. But we’ll see when we get there.
Let’s pick an easy one to do: we agree on ship maximum speed:
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
First step is to make a MaxSpeed parameter and make it work. We’ll save it in local storage as part of this effort. One thing at a time.
It’s clear that it matters when we look at the parameter. If we are happy only setting it between games, that’s one thing. If we want to tweak it in-game, that’s another. As we’re set above, new ships get whatever value in in the Ship class. Tozier is contemplating school zones in space where you have to slow down. I am ignoring him.
-- Main
parameter.number("VMax", 2, 20)
VMax = 20
This gives us a parameter and maxes it out. The result looks like this:
IMAGE HERE
MOVIE HERE
So this is a bit of a hack, because we did this in Ship:
function Ship:adjustedVelocity(vel, heading, accel)
local proposed = vel + accel:rotate(math.rad(heading))
proposed = proposed + U:gravity(self)
self.MaxSpeed = VMax
return U:limitSpeed(self, proposed)
end
This was needed because Universe checks the speed limit, as you’d imagine it might but why?
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
It’s nice to have this function at some global level like Universe. It’s not nice that it looks inside the object and rips out information: the object should pass in the max speed, not itself. We have discovered yet another little design flaw. Right now we’ll make a note of it because we’re putting in the tuning. However, fixing this may be the right way to do tuning, at the cost of a global refactoring of everyone who calls this function. We’ll let Codea’s replace guide us. Turns out only Ship and Fragment do this. An easy fix, so we’ll refactor the calling sequence.
This is not a violation of the “one-hat” rule, because we are green as regards our parameter.
function Ship:adjustedVelocity(vel, heading, accel)
local proposed = vel + accel:rotate(math.rad(heading))
proposed = proposed + U:gravity(self)
self.MaxSpeed = VMax
return U:limitSpeed(self.MaxSpeed, proposed)
end
function Fragment:adjustedVelocity(vel)
proposedV = vel + U:gravity(self)
return U:limitSpeed(self.MaxSpeed, proposedV)
end
function Universe:limitSpeed(max, vel)
local speed = vel:len()
if speed > max then
vel = vel*max/speed
end
return vel
end
This works, and now the Ship is using VMax as its top speed. We can refactor out that assignment into self.MaxSpeed if we wish. I think we need to think a bit more clearly about the relationship between the objects, the classes, and the parameters.
We’ve learned that VMax of 20 is too high, and 10 probably is not. We noticed that it takes too long to get to that speed, so we’ll need to tune thrust real soon now. We’re not entirely happy with the Ship querying these global parameters right in the middle of everything. But … what would be better? We discuss the local storage, which we believe will get read when the game starts (what do we mean by when the game starts: first execution from scratch start, not just New Game if we build that). REPHRASE THIS.
The idea is that we’ll wind up with these values pretty well tuned so that it will be rare for anyone to be tweaking them. Thus, store them.
But still, how and when should an object access these parameters? We could read these values upon Ship creation, for example, somewhere in here:
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
In here, we’d fetch the Ship’s VMax and store it in self.MaxSpeed. This allows my ship to be faster than yours if I can come up with a magic incantation. From whence do we fetch VMax? When does it come from the local storage, when from the Parameter? It must come from the Parameter, since otherwise there’s no useful way to test other speeds. So somewhere in BigInit we will read the local store and save it into the Parameter.
We probably need a save button to save current parm values into local storage. Let’s do that now. And we do and it works. We check that by starting the program, and VMax comes up 2, and we slide it to some other number (13), and we press our save button and restart the game and sure enough VMax comes up 13 next time.
--# 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.MaxSpeed, 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(max, vel)
local speed = vel:len()
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("Run", function()
displayMode(FULLSCREEN_NO_BUTTONS)
newGame()
end)
parameter.integer("VMax", 2, 20)
parameter.action("Save", function()
saveLocalData("VMax", VMax)
end)
VMax = readLocalData("VMax")
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)
self.MaxSpeed = VMax
return U:limitSpeed(self.MaxSpeed, 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