Asteroids 23
I think today we’ll do waves of asteroids. And some tests.
I watched some old Asteroids videos and have determined at least two useful things. First, when you destroy all the asteroids, a new wave starts with your ship wherever it was. Second, when your ship is destroyed, it returns in the middle of the screen, and the asteroids just keep on going. If one is about to hit the middle, too bad for you.
Actually, now that my brain is running (it’s 7 AM by the way), I recall some other facts. Waves are of size 4, 6, 8, 10, and 11. After 11 they just continue at 11. I think that was a 6502 capacity issue.
Asteroids always start at the screen edge at the beginning of a wave. (Ours are random on the screen at present.)
I’ve not sussed this out entirely, but it is clear that asteroid fragments generally move faster than the original (conservation of momentum? seems unlikely.) And they don’t start right at the position of the larger one, but offset from it. This will require more study of the 6502 code.
I think we’ll address most of what’s above, starting today. An additional observation that I think I’ve mentioned before is that our ship shape isn’t the same as the original. We’ll improve that one of these days.
And finally: there have been what, two or maybe even three defects discovered by my friends over on the Codea forum. That just won’t do. I do not accept that defects are inevitable in shipped products, even in the increments.
Mind you, I’m no fool. Well, maybe I am, but I’m not foolish enough to believe there will never be a defect, and certainly as I write these articles you’ve seen me make many mistakes of various kinds. But I’ve been programming for over a half-century, and what I’ve observed in the past two-plus decades of Agile Software Development is that teams can reduce their shipped defects by a factor of 100 or more. The two main ways they accomplish this is by working more closely together, and by microtesting in the TDD style.
For folks new to the idea, TDD, or Test-Driven Development, is a style of programming where, before you start putting in the code to do some new thing, you write a small, automated test, which my colleague GeePaw Hill calls a “microtest”. You run the test, seeing that it fails. You implement just enough code to make the test pass, rinse, repeat.
This sounds like it would be truly dreadful, especially if you think you hate testing. And it’s not trivial to get good at TDD, either. But once you’re in the groove, you just trip along adding tests and working code … because all the code you write is tested. For those of us who have practiced this skill, it’s really quite pleasant to do.
Then why am I not doing it here? The reason is that for microtesting to feel good, we have to be able to quickly write the next test. If testing is difficult, it doesn’t feel good and if it doesn’t feel good, we don’t do it.
And I do not usually see how to do decent microtests on graphical programs like this one, so my tests have so far been too few to mention.
However, the defects have been, while also few, more than I care to ship. I’d like each day’s release of this code to work exactly as intended. It might be short of a feature, or the BLAMMO might stay on the screen forever, but it would be that way because I decided it would be that way, not a mistake.
So I’m going to try to write more tests. I’m not optimistic that I’ll figure out how to make it go smoothly. But I’ll try, and in trying, I’ll learn something.
OK, let’s get to work.
Edges
Let’s start by changing our asteroid creation so that the asteroids all start out on a screen edge. I think we don’t need to care about their direction. If they start at the top going up, it’ll be a moment away from having started at the bottom.
We’d like them to be equally distributed around the four edges, and for their direction to continue to be random.
Hm. This is kind of an interesting constraint. Each asteroid will have one coordinate that is either, say, zero or screen size minus one, and the other coordinate random on the length of the other axis.
And how might we test some or all of that requirement?
Let’s think about how we might implement this requirement simply. My first sensible thought is this:
- first randomize both X and Y.
- then select one of four random setters, x = 0, x = WIDTH-1, y = 0, y = HEIGHT-1 and jam that value into the the result.
That seems weird. Maybe I was wrong about sensible. Let’s see. We only have four cases: <0,random>, <WIDTH, random>, <random, 0>, <random, HEIGHT>. Let’s just build four functions and call one randomly.
That might be good.
How to test that? I’m not at all sure. Testing random stuff is hard. Anyway, first I want to spike this code and see what it would look like. That’s allowed, isn’t it?
Spike
What about this:
function Asteroid:edgeCoordinate()
local r = math.random
local left = function()
return vec2(0,r(HEIGHT))
end
local right = function()
return vec2(WIDTH, r(HEIGHT))
end
local top = function()
return vec2(r(WIDTH,HEIGHT))
end
local bottom = function()
return vec2(rWIDTH), 0))
end
local tab = {right, left, top, bottom}
return tab[r(1,4)]()
end
I can sort of write a test for that:
_:test("edge coordinates", function()
for i = 0,1000 do
local v = Asteroid:edgeCoordinate()
local ok = v.x==0 or v.y==0 or v.x==WIDTH or v.y == HEIGHT
assert(ok).is(true)
end
end)
That’s not entirely satisfying, as it loops 1000 times but it’ll be quite fast. Does it run?
Well, no, it says “attempt to index a boolean value”. I don’t know whether that’s complaining about something in the test or in edgeCoordinate
. I’m not even really sure if it’s legit to call edgeCoordinate
as a class method like that.
One thing at a time. If I set ok
to true in the test, I get that message. So CodeaUnit isn’t OK testing booleans, I guess. I’ll explore that later.
Fool! Look what I wrote: assert
That’s Ruby thinking. We need expect
:
_:test("edge coordinates", function()
for i = 0,1000 do
local v = Asteroid:edgeCoordinate()
local ok = v.x==0 or v.y==0 or v.x==WIDTH or v.y == HEIGHT
_:expect(ok).is(true)
end
end)
Let me just point out that most authors would just go back and edit the past and put in the correct test. I want you to see that everyone does dumb things. Well, to see that I do, anyway.
So now we have an error at line 80 in Asteroid tab, “bad argument to ‘r’, interval is empty. Let’s see what that is about. Here are lines 79-81:
local top = function()
return vec2(r(WIDTH,HEIGHT))
end
The parens are in the wrong place.
local top = function()
return vec2(r(WIDTH),HEIGHT)
end
And voila! the edge coordinates have been tested 1000 times and always had a coordinate of 0 or screen size. Are we totally confident now? Probably not, but we have a reasonable belief that it’s working. I’m going to plug it in. Asteroid:init
looks like this:
function Asteroid:init()
self.pos = vec2(math.random(WIDTH), math.random(HEIGHT))
self.shape = Rocks[math.random(1,4)]
self.scale = 16
local angle = math.random()*2*math.pi
self.step = vec2(Vel,0):rotate(angle)
end
Changed to …
function Asteroid:init()
self.pos = self:edgeCoordinate()
self.shape = Rocks[math.random(1,4)]
self.scale = 16
local angle = math.random()*2*math.pi
self.step = vec2(Vel,0):rotate(angle)
end
And it works. And I don’t like it. With the values selected randomly, it can happen that all the asteroids start on one side and that looks weird. I think I’ll make it cycle, using the index of the asteroid we’re creating. This does mean I need to change the unit test, because now the function’s not doing to be random:
… ten minutes pass …
Darn! This isn’t going to work as I intended. My plan was to call edgeCoordinate
in the asteroid creation, passing in the index of the one we’re creating. That was slightly messy already because as the indexes increase we need to mod them and i mod 4 is in 0-3, not 1-4, so fudge factor. But then splitAsteroid
wants to set its own position.
Well, maybe its OK. I’ll just pass in a value and override it:
function splitAsteroid(asteroid, asteroids)
if asteroid.scale == 4 then
Splat(asteroid.pos)
DeadAsteroids[asteroid] = asteroid
return
end
asteroid.scale = asteroid.scale//2
asteroid.angle = math.random()*2*math.pi
local new = Asteroid(1)
new.pos = asteroid.pos
new.scale = asteroid.scale
asteroids[new] = new
Splat(asteroid.pos)
end
Meanwhile my test actually works:
_:test("edge coordinates", function()
_:expect(Asteroid:edgeCoordinate(5).x).is(WIDTH)
_:expect(Asteroid:edgeCoordinate(6).x).is(0)
_:expect(Asteroid:edgeCoordinate(7).y).is(HEIGHT)
_:expect(Asteroid:edgeCoordinate(8).y).is(0)
end)
Note that I gave it coordinates above 4 to make sure the modulus stuff was working:
function Asteroid:edgeCoordinate(index)
index = index%4
if index == 0 then index = 4 end
local r = math.random
local left = function()
return vec2(0,r(HEIGHT))
end
local right = function()
return vec2(WIDTH, r(HEIGHT))
end
local top = function()
return vec2(r(WIDTH),HEIGHT)
end
local bottom = function()
return vec2(r(WIDTH), 0)
end
local tab = {right, left, top, bottom}
return tab[index]()
end
That’s simple but a bit nasty. Shouldn’t matter, we can clean it if we wish. Does the game run?
It does, but I’m still not entirely happy. You’d kind of like the asteroids to come falling inward from all four edges, and half the time they come in pairs from top or bottom. They’re rezzing correctly, but then their random direction means they might both go roughly the same way. So I don’t love the look of that.
I have the feeling that the right thing to do is revert and start over. I can rationalize that the edge code is moving in the right direction, and so on, but my gut tells me it’s not going well. When that’s the case, the right thing – for me – is to revert and start over. I’ve surely learned something from this and it’ll be better to start with that learning and no code than with code I don’t like.
It’s easily done: Working Copy has a Revert Changes button. And I’m pressing it … now.
Begin Anew
Let’s defer the edge starting issue for now, and deal with waves. Two main aspects. First, when all the asteroids are gone, run another wave, leaving the ship where it is. Second, wave sizes go 4, 6, 8, 10, 11.
I have a feeling I’ll never get there in the game, at least not without hyperspace, or a switch to turn off asteroid-ship collisions.
Are there tests to write? Well, we’ll need to keep the number of ships in the wave in Universe, and increment it in that odd way. So let’s posit a function, Universe:newWaveSize
that does that job for us. A side question is whether that will be called at the beginning of the game and my sense of symmetry says that it should be. So a test:
_:test("Wave size", function()
local u = Universe()
_:expect(u:newWaveSize()).is(4)
_:expect(u:newWaveSize()).is(6)
_:expect(u:newWaveSize()).is(8)
_:expect(u:newWaveSize()).is(10)
_:expect(u:newWaveSize()).is(11)
_:expect(u:newWaveSize()).is(11)
end)
First cut at the implementation:
function Universe:newWaveSize()
self.waveSize = self.waveSize + 2
if self.waveSize > 11 then self.waveSize = 11 end
end
(With initializing it to 2 in Universe:init
.) I don’t like that, it’s too much of a tricky hack. Let’s see, what would be better?
(Is it driving you crazy that I’m fussing over this tiny detail? It’s driving me crazy too. But this code is fragile. It’s not very critical, but it is fragile. I’m going to remove the init of waveSize
from init
and make it work here.)
function Universe:newWaveSize()
self.waveSize = (self.waveSize or 2) + 2
if self.waveSize > 11 then self.waveSize = 11 end
return self.waveSize
end
OK. This is now the only guy who knows there is a waveSize
value in U
. He takes care of initializing it and updating it. Now lets see about putting it into Universe.
We want Universe to call “newWave” to create the first wave (and any subsequent waves). And while we’re at it, let’s fix this:
function setup()
U = Universe()
U:createAsteroids()
end
setup
should just create the universe, and universe should create the waves. So I’ll delete that line above and move into the Universe.
We have this:
function Universe:createAsteroids()
for i = 1,4 do
local a = Asteroid()
self.asteroids[a] = a
end
end
Which I’ll rename newWave
and change to call newWaveSize
.
My spidey sense is tingling. I have no real reason to change that loop to newWaveSize
yet, because there is never a wave bigger than size 4 yet. This is speculative and in principle I should change it when it’s needed.
I’m coding rather poorly today, and I’m aware of it. We have good days and bad ones and today is at best a mediocre one. But I’m sort of having fun so let’s continue.
I’m going ahead and putting in the call to newWaveSize
:
function Universe:newWave()
for i = 1, self:newWaveSize() do
local a = Asteroid()
self.asteroids[a] = a
end
end
Super! That misbehaves in a lovely new way. self.asteroids
isn’t initialized yet, because of where I put the create in the init
. I put it there in the middle, aware of the general problem, but I didn’t expect it to explode.
What we should do in init
is initialize all the constants, then all the empty tables, and then we should create stuff. For now, I’ll just reorder the method … and then refactor:
function Universe:init()
self.processorRatio = 1.0
self.score = 0
self.missileVelocity = vec2(MissileSpeed,0)
self.button = {}
self.asteroids = {}
self.missiles = {}
self.explosions = {}
createButtons()
self:newWave()
self.ship = Ship()
end
First to make sure we’re still running. And the game runs, but my newWaveSize
test fails, returning 2 more than I expect every time. What’s up with that?
Ah. I’ve changed Universe
so that it creates a wave right out of the box. So it has already used up 4 and then my test gets 6. I think I’l hammer my private universe back to nil:
_:test("Wave size", function()
local u = Universe()
u.waveSize = nil
_:expect(u:newWaveSize()).is(4)
_:expect(u:newWaveSize()).is(6)
_:expect(u:newWaveSize()).is(8)
_:expect(u:newWaveSize()).is(10)
_:expect(u:newWaveSize()).is(11)
_:expect(u:newWaveSize()).is(11)
end)
Having this new test, and my other ones, is giving me a bit of comfort, as it always does. So that’s good. Now what should really happen here?
Well, the game should idle, in “attract mode”, with asteroids floating around, until the user puts in his quarters and presses the start button. Then the screen clears and the game starts.
Let’s make this happen, shall we? How shall we signal the start? We could make a new start button, but that’s more than I want to do right now. Let’s do this.
When Universe inits, it sets self.attractMode
. It creates a wave of asteroids but nothing else, so they float around looking good. Then, in Main, we’ll check for a screen touch of any kind, in attract mode, and when we get one, we’ll call U:startGame
. That should be “easy”.
function Universe:init()
self.processorRatio = 1.0
self.score = 0
self.missileVelocity = vec2(MissileSpeed,0)
self.button = {}
self.asteroids = {}
self.missiles = {}
self.explosions = {}
self.attractMode = true
self:newWave()
end
function Universe:startGame()
createButtons()
self.ship = Ship()
self.asteroids = {}
self:newWave()
end
function touched(touch)
if U.attractMode and touch.state == ENDED then U:startGame() end
if touch.state == ENDED or touch.state == CANCELLED then
Touches[touch.id] = nil
else
Touches[touch.id] = touch
end
end
That actually works, except (you guessed it) we get six asteroids, not 4 at the beginning. So:
function Universe:startGame()
createButtons()
self.ship = Ship()
self.asteroids = {}
self.waveSize = nil
self:newWave()
end
Ha. That worked except for not clearing attractMode
:
function Universe:startGame()
self.attractMode = false
createButtons()
self.ship = Ship()
self.asteroids = {}
self.waveSize = nil
self:newWave()
end
OK, now we have a game start thing. We could put a “touch screen to start” message up, and maybe we should do that. I think for now, I’ll put that in Main, but that’s only good for the short term.
function draw()
U:draw()
if U.attractMode then
pushStyle()
fontSize(50)
fill(255,255,255, 128)
text("TOUCH SCREEN TO START", WIDTH/2, HEIGHT/4)
popStyle()
end
end
So now it goes like this:
That should be enough to prove that I’m terrible at playing the game with the tablet in keyboard position. Also it’s clear that rotation is too slow. But let’s commit, we have something working: “game start and newWave”.
Turning
Let’s double the turning rate and if it isn’t already a Universe constant, make it so:
local rotationStep = math.rad(1) -- one degree in radians
function Ship:move()
if U.button.left then self.radians = self.radians + rotationStep end
if U.button.right then self.radians = self.radians - rotationStep end
if U.button.fire then if not self.holdFire then self:fireMissile() end end
if not U.button.fire then self.holdFire = false end
self:actualShipMove()
end
Let’s move constant to Universe and double it:
function Universe:init()
self.processorRatio = 1.0
self.score = 0
self.rotationStep = math.rad(2) -- degrees
self.missileVelocity = vec2(MissileSpeed,0)
self.button = {}
self.asteroids = {}
self.missiles = {}
self.explosions = {}
self.attractMode = true
self:newWave()
end
function Ship:move()
if U.button.left then self.radians = self.radians + U.rotationStep end
if U.button.right then self.radians = self.radians - U.rotationStep end
if U.button.fire then if not self.holdFire then self:fireMissile() end end
if not U.button.fire then self.holdFire = false end
self:actualShipMove()
end
That’s actually a bit too fast, I think. And doesn’t it need to be adjusted by the processor speed? I think it does. I want knowledge of that ratio to stay inside Universe, so a new function to return adjustedRotationSpeed
:
self.rotationStep = math.rad(1.5) -- degrees
function Universe:adjustedRotationStep()
return self.processorRatio*self.rotationStep
end
function Ship:move()
if U.button.left then self.radians = self.radians + U:adjustedRotationStep() end
if U.button.right then self.radians = self.radians - U:adjustedRotationStep() end
if U.button.fire then if not self.holdFire then self:fireMissile() end end
if not U.button.fire then self.holdFire = false end
self:actualShipMove()
end
That’s a bit better, perhaps a bit slow. I’m definitely not good at controlling this thing in typing position, and not much better when flat. I suspect the controls need to be repositioned or refined.
I don’t remember whether I mentioned that Dave1707 from the Codea forum moved “left” just above “right,” both on the left edge and found that better. We may need to provide a control adjustment mode but I’m sure that’ll be much later.
Anyway turning is better. Commit: “turn parameterized, ratio’d and sped up”
Enough for now, I think …
Summing Up
I was sufficiently inept today to make me want to ditch this article. Even though I like showing things “warts and all”, today I was fumbling around rather more than I’m used to.
Writing the tests helped to get me on the right track, and changing to a different feature helped as well. Kind of a reset for the mind.
Alls well that ends, though, and we have an attract mode screen, and a touch to start and we’re ready for multiple waves. I may have to implement a special “Ron” mode that kills all the current asteroids to ever get to a new wave. Or maybe it just protects me from collisions.
The Universe is slightly better organized, so that’s an improvement.
I’ll break for now and perhaps come back later today. Meanwhile, feel free to point at me and laugh.
I’ll include a copy of CodeaUnit as I use it, in case you want to play with the tests. It’ll be down at the bottom, so be careful what you copy and paste.
See you next time!
--# Main
-- Asteroids
-- RJ 20200511
Touches = {}
function setup()
U = Universe()
end
function draw()
U:draw()
if U.attractMode then
pushStyle()
fontSize(50)
fill(255,255,255, 128)
text("TOUCH SCREEN TO START", WIDTH/2, HEIGHT/4)
popStyle()
end
end
function touched(touch)
if U.attractMode and touch.state == ENDED then U:startGame() end
if touch.state == ENDED or touch.state == CANCELLED then
Touches[touch.id] = nil
else
Touches[touch.id] = touch
end
end
--# TestAsteroids
-- TestAsteroids
-- RJ 20200511
function testAsteroids()
CodeaUnit.detailed = true
_:describe("Asteroids First Tests", function()
_:before(function()
-- Some setup
end)
_:after(function()
-- Some teardown
end)
_:test("Hookup", function()
_:expect( 2+1 ).is(3)
end)
_:test("Random", function()
local min = 100
local max = 0
for i = 0,1000 do
local rand = math.random()*2*math.pi
if rand < min then min = rand end
if rand > max then max = rand end
end
_:expect(min < 0.01).is(true)
_:expect(max > 6.2).is(true)
end)
_:test("Rotated Length", function()
for i = 0, 1000 do
local rand = math.random()*2*math.pi
local v = vec2(1.5,0):rotate(rand)
local d = v:len()
_:expect(d > 1.495).is(true)
_:expect(d < 1.505).is(true)
end
end)
_:test("Some rotates go down", function()
local angle = math.rad(-45)
local v = vec2(1,0):rotate(angle)
local rvx = v.x*1000//1
local rvy = v.y*1000//1
_:expect(rvx).is(707)
_:expect(rvy).is(-708)
end)
_:test("Bounds function", function()
_:expect(U:keepInBounds(100,1000)).is(100)
_:expect(U:keepInBounds(1000,1000)).is(0)
_:expect(U:keepInBounds(1001,1000)).is(1)
_:expect(U:keepInBounds(-1,1000)).is(999)
end)
_:test("Missile fired at rest", function()
local ship = Ship()
local missile = Missile(ship)
_:expect(missile.step).is(U.missileVelocity)
end)
_:test("Missile fired north", function()
local ship = Ship()
ship.radians = math.pi/2
local missile = Missile(ship)
local mx = missile.step.x
local my = missile.step.y
_:expect(mx).is(0, 0.001)
_:expect(my).is(U.missileVelocity.x, 0.001)
end)
_:test("Missile fired from moving ship", function()
local ship = Ship()
ship.step = vec2(1,2)
local missile = Missile(ship)
local mx = missile.step.x
local my = missile.step.y
_:expect(mx).is(U.missileVelocity.x + 1, 0.001)
_:expect(my).is(U.missileVelocity.y + 2, 0.001)
end)
_:test("Asteroids increment score", function()
local a = Asteroid()
U.score = 0
scoreAsteroid(a)
_:expect(U.score).is(20)
end)
_:test("Wave size", function()
local u = Universe()
u.waveSize = nil
_:expect(u:newWaveSize()).is(4)
_:expect(u:newWaveSize()).is(6)
_:expect(u:newWaveSize()).is(8)
_:expect(u:newWaveSize()).is(10)
_:expect(u:newWaveSize()).is(11)
_:expect(u:newWaveSize()).is(11)
end)
end)
end
--# Shapes
RR1 = {
vec4(0.000000, 2.000000, 2.000000, 4.000000),
vec4(2.000000, 4.000000, 4.000000, 2.000000),
vec4(4.000000, 2.000000, 3.000000, 0.000000),
vec4(3.000000, 0.000000, 4.000000, -2.000000),
vec4(4.000000, -2.000000, 1.000000, -4.000000),
vec4(1.000000, -4.000000, -2.000000, -4.000000),
vec4(-2.000000, -4.000000, -4.000000, -2.000000),
vec4(-4.000000, -2.000000, -4.000000, 2.000000),
vec4(-4.000000, 2.000000, -2.000000, 4.000000),
vec4(-2.000000, 4.000000, 0.000000, 2.000000)
}
RR2 = {
vec4(2.000000, 1.000000, 4.000000, 2.000000),
vec4(4.000000, 2.000000, 2.000000, 4.000000),
vec4(2.000000, 4.000000, 0.000000, 3.000000),
vec4(0.000000, 3.000000, -2.000000, 4.000000),
vec4(-2.000000, 4.000000, -4.000000, 2.000000),
vec4(-4.000000, 2.000000, -3.000000, 0.000000),
vec4(-3.000000, 0.000000, -4.000000, -2.000000),
vec4(-4.000000, -2.000000, -2.000000, -4.000000),
vec4(-2.000000, -4.000000, -1.000000, -3.000000),
vec4(-1.000000, -3.000000, 2.000000, -4.000000),
vec4(2.000000, -4.000000, 4.000000, -1.000000),
vec4(4.000000, -1.000000, 2.000000, 1.000000)
}
RR3 = {
vec4(-2.000000, 0.000000, -4.000000, -1.000000),
vec4(-4.000000, -1.000000, -2.000000, -4.000000),
vec4(-2.000000, -4.000000, 0.000000, -1.000000),
vec4(0.000000, -1.000000, 0.000000, -4.000000),
vec4(0.000000, -4.000000, 2.000000, -4.000000),
vec4(2.000000, -4.000000, 4.000000, -1.000000),
vec4(4.000000, -1.000000, 4.000000, 1.000000),
vec4(4.000000, 1.000000, 2.000000, 4.000000),
vec4(2.000000, 4.000000, -1.000000, 4.000000),
vec4(-1.000000, 4.000000, -4.000000, 1.000000),
vec4(-4.000000, 1.000000, -2.000000, 0.000000)
}
RR4 = {
vec4(1.000000, 0.000000, 4.000000, 1.000000),
vec4(4.000000, 1.000000, 4.000000, 2.000000),
vec4(4.000000, 2.000000, 1.000000, 4.000000),
vec4(1.000000, 4.000000, -2.000000, 4.000000),
vec4(-2.000000, 4.000000, -1.000000, 2.000000),
vec4(-1.000000, 2.000000, -4.000000, 2.000000),
vec4(-4.000000, 2.000000, -4.000000, -1.000000),
vec4(-4.000000, -1.000000, -2.000000, -4.000000),
vec4(-2.000000, -4.000000, 1.000000, -3.000000),
vec4(1.000000, -3.000000, 2.000000, -4.000000),
vec4(2.000000, -4.000000, 4.000000, -2.000000),
vec4(4.000000, -2.000000, 1.000000, 0.000000)
}
Rocks = {RR1,RR2,RR3,RR4}
--# Ship
-- Ship
-- RJ 20200520
Ship = class()
function Ship:init()
self.pos = vec2(WIDTH, HEIGHT)/2
self.radians = 0
self.step = vec2(0,0)
end
function Ship:draw()
local sx = 10
local sy = 6
pushStyle()
pushMatrix()
translate(self.pos.x, self.pos.y)
rotate(math.deg(self.radians))
strokeWidth(2)
stroke(255)
line(sx,0, -sx,sy)
line(-sx,sy, -sx,-sy)
line(-sx,-sy, sx,0)
popMatrix()
popStyle()
end
function Ship:move()
if U.button.left then self.radians = self.radians + U:adjustedRotationStep() end
if U.button.right then self.radians = self.radians - U:adjustedRotationStep() end
if U.button.fire then if not self.holdFire then self:fireMissile() end end
if not U.button.fire then self.holdFire = false end
self:actualShipMove()
end
function Ship:actualShipMove()
if U.button.go then
local accel = vec2(0.015,0):rotate(self.radians)
self.step = self.step + accel
self.step = maximize(self.step, 3)
end
self:finallyMove()
end
function Ship:finallyMove()
U:moveObject(self)
end
function maximize(vec, size)
local s = vec:len()
if s <= size then
return vec
else
return vec*size/s
end
end
function Ship:fireMissile()
self.holdFire = true
Missile(self)
end
--# Button
-- Button
-- RJ 20200520
local Buttons = {}
function createButtons()
local dx=50
local dy=200
table.insert(Buttons, {x=dx, y=dy, name="left"})
table.insert(Buttons, {x=dy, y=dx, name="right"})
table.insert(Buttons, {x=WIDTH-dx, y=dy, name="fire"})
table.insert(Buttons, {x=WIDTH-dy, y=dx, name = "go"})
end
function checkButtons()
U.button.left = false
U.button.right = false
U.button.go = false
U.button.fire = false
for id,touch in pairs(Touches) do
for i,button in ipairs(Buttons) do
if touch.pos:dist(vec2(button.x,button.y)) < 50 then
U.button[button.name]=true
end
end
end
end
function drawButtons()
pushStyle()
ellipseMode(RADIUS)
textMode(CENTER)
stroke(255)
strokeWidth(1)
for i,b in ipairs(Buttons) do
pushMatrix()
pushStyle()
translate(b.x,b.y)
if U.button[b.name] then
fill(128,0,0)
else
fill(128,128,128,128)
end
ellipse(0,0, 50)
fill(255)
fontSize(30)
text(b.name,0,0)
popStyle()
popMatrix()
end
popStyle()
end
--# Asteroid
-- Asteroid
-- RJ 20200520
local DeadAsteroids = {}
local Vel = 1.5
Asteroid = class()
function Asteroid:init()
self.pos = vec2(math.random(WIDTH), math.random(HEIGHT))
self.shape = Rocks[math.random(1,4)]
self.scale = 16
local angle = math.random()*2*math.pi
self.step = vec2(Vel,0):rotate(angle)
end
function Asteroid:killDist()
local s = self.scale
if s == 16 then return 64 elseif s == 8 then return 32 else return 16 end
end
function killDeadAsteroids(asteroids)
for k,a in pairs(DeadAsteroids) do
asteroids[a] = nil
end
DeadAsteroids = {}
end
function scoreAsteroid(asteroid)
local s = asteroid.scale
local inc = 0
if s == 16 then inc = 20
elseif s == 8 then inc = 50
else inc = 100
end
U.score = U.score + inc
end
function splitAsteroid(asteroid, asteroids)
if asteroid.scale == 4 then
Splat(asteroid.pos)
DeadAsteroids[asteroid] = asteroid
return
end
asteroid.scale = asteroid.scale//2
asteroid.angle = math.random()*2*math.pi
local new = Asteroid()
new.pos = asteroid.pos
new.scale = asteroid.scale
asteroids[new] = new
Splat(asteroid.pos)
end
function Asteroid:draw()
pushMatrix()
pushStyle()
translate(self.pos.x, self.pos.y)
scale(self.scale)
strokeWidth(1/self.scale)
for i,l in ipairs(self.shape) do
line(l.x, l.y, l.z, l.w)
end
popStyle()
popMatrix()
end
function Asteroid:move()
U:moveObject(self)
end
--# Splat
-- Splat
-- RJ 20200521
local Splats = {}
local Vecs = {
vec2(-2,0), vec2(-2,-2), vec2(2,-2), vec2(3,1), vec2(2,-1), vec2(0,2), vec2(1,3), vec2(-1,3), vec2(-4,-1), vec2(-3,1)
}
function drawSplats()
for k, splat in pairs(Splats) do
splat:draw()
end
end
Splat = class()
function Splat:init(pos)
local die = function()
Splats[self] = nil
end
self.pos = pos
Splats[self] = self
self.size = 2
self.diameter = 6
self.rot = math.random(0,359)
tween(4, self, {size=10, diameter=1}, tween.easing.linear, die)
end
function Splat:draw()
pushStyle()
pushMatrix()
translate(self.pos.x, self.pos.y)
fill(255)
stroke(255)
rotate(self.rot)
local s = self.size
for i,v in ipairs(Vecs) do
ellipse(s*v.x, s*v.y, self.diameter)
end
popMatrix()
popStyle()
end
--# Missile
-- Missile
-- RJ 20200522
Missile = class()
function Missile:init(ship)
function die()
self:die()
end
self.pos = ship.pos
self.step = U.missileVelocity:rotate(ship.radians) + ship.step
U.missiles[self] = self
tween(3, self, {}, tween.easing.linear, die)
end
function Missile:die()
U.missiles[self] = nil
end
function Missile:draw()
ellipse(self.pos.x, self.pos.y, 6)
end
function Missile:move()
U:moveObject(self)
end
--# Universe
-- Universe
-- RJ 20200523
Universe = class()
local MissileSpeed = 2.0
function Universe:init()
self.processorRatio = 1.0
self.score = 0
self.rotationStep = math.rad(1.5) -- degrees
self.missileVelocity = vec2(MissileSpeed,0)
self.button = {}
self.asteroids = {}
self.missiles = {}
self.explosions = {}
self.attractMode = true
self:newWave()
end
function Universe:startGame()
self.attractMode = false
createButtons()
self.ship = Ship()
self.asteroids = {}
self.waveSize = nil
self:newWave()
end
function Universe:draw()
displayMode(FULLSCREEN_NO_BUTTONS)
pushStyle()
background(40, 40, 50)
self.processorRatio = DeltaTime/0.0083333
self:drawAsteroids()
self:drawExplosions()
checkButtons()
drawButtons()
if self.ship then self.ship:draw() end
if self.ship then self.ship:move() end
self:drawMissiles()
drawSplats()
U:drawScore()
popStyle()
U:findCollisions()
end
function Universe:newWave()
for i = 1, self:newWaveSize() do
local a = Asteroid()
self.asteroids[a] = a
end
end
function Universe:findCollisions()
for i,a in pairs(self.asteroids) do
self:checkMissileCollisions(a)
if self.ship then self:checkShipCollision(a) end
end
end
function Universe:checkShipCollision(asteroid)
if self.ship.pos:dist(asteroid.pos) < asteroid:killDist() then
scoreAsteroid(asteroid)
splitAsteroid(asteroid, self.asteroids)
self:killShip()
end
end
function Universe:checkMissileCollisions(asteroid)
for k,m in pairs(self.missiles) do
if m.pos:dist(asteroid.pos) < asteroid:killDist() then
scoreAsteroid(asteroid)
splitAsteroid(asteroid, self.asteroids)
m:die()
end
end
end
function Universe:killShip()
local f = function()
self.ship = Ship()
end
Explosion(U.ship)
U.ship = nil
tween(6, self, {}, tween.easing.linear, f)
end
function Universe:moveObject(anObject)
local pos = anObject.pos + self.processorRatio*anObject.step
anObject.pos = vec2(self:keepInBounds(pos.x, WIDTH), self:keepInBounds(pos.y, HEIGHT))
end
function Universe:keepInBounds(value, bound)
return (value+bound)%bound
end
function Universe:drawExplosions()
for k,e in pairs(self.explosions) do
e:draw()
end
end
function Universe:drawMissiles()
pushStyle()
pushMatrix()
fill(255)
stroke(255)
for k, missile in pairs(self.missiles) do
missile:draw()
end
popMatrix()
popStyle()
for k, missile in pairs(self.missiles) do
missile:move()
end
end
function Universe:drawAsteroids()
pushStyle()
stroke(255)
fill(0,0,0, 0)
strokeWidth(2)
rectMode(CENTER)
for i,asteroid in pairs(self.asteroids) do
asteroid:draw()
asteroid:move()
end
popStyle()
killDeadAsteroids(self.asteroids)
end
function Universe:drawScore()
local s= "000000"..tostring(self.score)
s = string.sub(s,-5)
fontSize(100)
text(s, 200, HEIGHT-60)
end
function Universe:newWaveSize()
self.waveSize = (self.waveSize or 2) + 2
if self.waveSize > 11 then self.waveSize = 11 end
return self.waveSize
end
function Universe:adjustedRotationStep()
return self.processorRatio*self.rotationStep
end
--# Explosion
Explosion = class()
function Explosion:init(ship)
local f = function()
U.explosions[self] = nil
end
self.pos = ship.pos
self.step = vec2(0,0)
U.explosions[self] = self
tween(4, self, {}, tween.easing.linear, f)
end
function Explosion:draw()
pushStyle()
pushMatrix()
translate(self.pos.x, self.pos.y)
fontSize(30)
text("BLAMMO", 0, 0)
popMatrix()
popStyle()
end
CodeaUnit ======================================
--# CodeaUnit
CodeaUnit = class()
function CodeaUnit:describe(feature, allTests)
self.tests = 0
self.ignored = 0
self.failures = 0
self._before = function()
end
self._after = function()
end
print(string.format("Feature: %s", feature))
allTests()
local passed = self.tests - self.failures - self.ignored
local summary = string.format("%d Passed, %d Ignored, %d Failed", passed, self.ignored, self.failures)
print(summary)
end
function CodeaUnit:before(setup)
self._before = setup
end
function CodeaUnit:after(teardown)
self._after = teardown
end
function CodeaUnit:ignore(description, scenario)
self.description = tostring(description or "")
self.tests = self.tests + 1
self.ignored = self.ignored + 1
if CodeaUnit.detailed then
print(string.format("%d: %s -- Ignored", self.tests, self.description))
end
end
function CodeaUnit:test(description, scenario)
self.description = tostring(description or "")
self.tests = self.tests + 1
self._before()
local status, err = pcall(scenario)
if err then
self.failures = self.failures + 1
print(string.format("%d: %s -- %s", self.tests, self.description, err))
end
self._after()
end
function CodeaUnit:expect(conditional)
local message = string.format("%d: %s", (self.tests or 1), self.description)
local passed = function()
if CodeaUnit.detailed then
print(string.format("%s -- OK", message))
end
end
local failed = function()
self.failures = self.failures + 1
local actual = tostring(conditional)
local expected = tostring(self.expected)
print(string.format("%s -- Actual: %s, Expected: %s", message, actual, expected))
end
local notify = function(result)
if result then
passed()
else
failed()
end
end
local is = function(expected, epsilon)
self.expected = expected
if epsilon then
notify(expected - epsilon <= conditional and conditional <= expected + epsilon)
else
notify(conditional == expected)
end
end
local isnt = function(expected)
self.expected = expected
notify(conditional ~= expected)
end
local has = function(expected)
self.expected = expected
local found = false
for i,v in pairs(conditional) do
if v == expected then
found = true
end
end
notify(found)
end
local hasnt = function(expected)
self.expected = expected
local missing = true
for i,v in pairs(conditional) do
if v == expected then
missing = false
end
end
notify(missing)
end
local throws = function(expected)
self.expected = expected
local status, error = pcall(conditional)
if not error then
conditional = "nothing thrown"
notify(false)
else
notify(string.find(error, expected, 1, true))
end
end
return {
is = is,
isnt = isnt,
has = has,
hasnt = hasnt,
throws = throws
}
end
CodeaUnit.execute = function()
for i,v in pairs(listProjectTabs()) do
local source = readProjectTab(v)
for match in string.gmatch(source, "function%s-(test.-%(%))") do
print("loading", match)
loadstring(match)()
end
end
end
CodeaUnit.detailed = true
_ = CodeaUnit()
parameter.action("CodeaUnit Runner", function()
CodeaUnit.execute()
end)
--# Main
function setup()
img = readImage(asset.documents.unitpic)
sprite(img,500,600)
saveImage(asset.."Icon",img)
end
function draw()
sprite(img, 500,600)
end