840 lines
27 KiB
Text
840 lines
27 KiB
Text
== Lesson 7: A Game
|
|
|
|
image::learn/07-a-game.png[The finished lesson, 480]
|
|
|
|
Six lessons ago you put one word on a black screen. Since then you have
|
|
learned every idea a small game is made of: variables, decisions, loops,
|
|
functions of your own, and lists of things. This lesson does not teach a new
|
|
idea. It spends the ones you have.
|
|
|
|
You are going to build a game called Dodge. Blocks fall out of the sky, you
|
|
slide a bar along the bottom to get out of their way, and every block that
|
|
misses you is a point. Hit three of them and it is over. You will build it in
|
|
six stages, and every one of them runs: you never have to type a hundred
|
|
lines and hope.
|
|
|
|
=== Make the Folder
|
|
|
|
Make a folder called `dodge`, and in it a file called `dodge.singe`. Run it
|
|
the way you have been running everything since lesson one:
|
|
|
|
----
|
|
Singe -R dodge
|
|
----
|
|
|
|
Leave that running in a terminal for the whole lesson. Every time you save,
|
|
the game restarts with your change in it.
|
|
|
|
=== Stage One: Something You Can Steer
|
|
|
|
Type this in, save, and steer it with the left and right arrow keys.
|
|
|
|
[source,lua]
|
|
----
|
|
dofile("Singe/Framework.singe")
|
|
|
|
local PLAYER_WIDTH = 40
|
|
local PLAYER_HEIGHT = 8
|
|
local PLAYER_SPEED = 4
|
|
local PLAYER_MARGIN = 4
|
|
|
|
local screenWidth = overlayGetWidth()
|
|
local screenHeight = overlayGetHeight()
|
|
local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN
|
|
|
|
local playerX = (screenWidth - PLAYER_WIDTH) / 2
|
|
local goingLeft = false
|
|
local goingRight = false
|
|
|
|
|
|
function onInputPressed(what)
|
|
if what == SWITCH_LEFT then
|
|
goingLeft = true
|
|
elseif what == SWITCH_RIGHT then
|
|
goingRight = true
|
|
end
|
|
end
|
|
|
|
|
|
function onInputReleased(what)
|
|
if what == SWITCH_LEFT then
|
|
goingLeft = false
|
|
elseif what == SWITCH_RIGHT then
|
|
goingRight = false
|
|
end
|
|
end
|
|
|
|
|
|
function onOverlayUpdate()
|
|
if goingLeft then
|
|
playerX = playerX - PLAYER_SPEED
|
|
end
|
|
if goingRight then
|
|
playerX = playerX + PLAYER_SPEED
|
|
end
|
|
if playerX < 0 then
|
|
playerX = 0
|
|
elseif playerX > screenWidth - PLAYER_WIDTH then
|
|
playerX = screenWidth - PLAYER_WIDTH
|
|
end
|
|
|
|
overlayClear()
|
|
colorForeground(80, 255, 160)
|
|
overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1)
|
|
return OVERLAY_UPDATED
|
|
end
|
|
----
|
|
|
|
Fifty lines, and almost none of it is new. Two things in it are, so take the
|
|
first line first.
|
|
|
|
[source,lua]
|
|
----
|
|
dofile("Singe/Framework.singe")
|
|
----
|
|
|
|
`dofile` belongs to Lua rather than to Singe, and it means "go and run that
|
|
file, then carry on here". The file it runs is `Singe/Framework.singe`, one of
|
|
the files the engine unpacked into your work folder the first time you ran
|
|
anything, back in lesson one. Open it in your text editor and look: it is
|
|
ordinary Lua, no different in kind from what you have been writing since
|
|
lesson two, and there is nothing magic in it.
|
|
|
|
Running it hands your script a set of names it can then use. The ones this
|
|
book reaches for are these:
|
|
|
|
* `DIR`, which holds the folder your own script is sitting in. Lesson nine
|
|
needs it the moment a game loads a picture, and it is the real reason this
|
|
line is here.
|
|
* `SCANCODE` and `MODIFIER`, names for every key on a keyboard, for a game
|
|
that reads typing rather than a joystick.
|
|
* `GAMEPAD_0` to `GAMEPAD_3` and `MOUSE_0` to `MOUSE_3`, for a cabinet with
|
|
more than one of something plugged into it.
|
|
* A few helpers whose names begin with `util`, and the six calls that post a
|
|
score to a leaderboard, which is lesson twenty-eight.
|
|
* The second way of writing a game altogether, which is lesson fifteen.
|
|
|
|
One thing it does *not* give you, because it is the natural thing to assume.
|
|
`SWITCH_LEFT`, and every other name beginning with `SWITCH_`, comes from the
|
|
engine itself, before any script runs at all. That is why lessons three to six
|
|
could read the arrow keys without loading anything. The framework adds to what
|
|
is already there; it does not provide it.
|
|
|
|
This particular game uses nothing from the framework. The line is here because
|
|
every game from lesson nine to the end of the book does need it, and because
|
|
it is how nearly every real Singe game starts. It is read once, at startup,
|
|
and then forgotten about.
|
|
|
|
The second new thing is the way the arrow keys are handled, because it is the
|
|
first thing beginners get wrong.
|
|
|
|
`onInputPressed` does not move the player. It sets `goingLeft` to `true`, and
|
|
`onInputReleased` sets it back to `false`. The moving happens in
|
|
`onOverlayUpdate`, which asks "is the player holding left?" sixty times a
|
|
second. If you moved the player inside `onInputPressed` instead, you would get
|
|
one step per press: Singe sends a press exactly once when the key goes down
|
|
and never repeats it, which the manual's entry for `onInputPressed` says
|
|
plainly. Holding the key would do nothing at all.
|
|
|
|
So the press handler records what is true about the world, and the update
|
|
decides what to do about it. Keep that split and your input code will stay
|
|
five lines long forever.
|
|
|
|
The three names in capitals are ordinary variables. Writing `PLAYER_SPEED` in
|
|
capitals is a message to yourself and to anyone reading: this one is set once
|
|
at the top and never changes. Nothing in Lua enforces it. It matters because
|
|
in a minute there will be ten of them, and when the game is too hard you want
|
|
one obvious place to go and turn a number down.
|
|
|
|
`screenWidth` and `screenHeight` come from the engine rather than being typed
|
|
in, so the game lays itself out to whatever size the overlay happens to be.
|
|
`playerY` is worked out once from the height: the bar sits `PLAYER_MARGIN`
|
|
pixels off the bottom and never moves up or down, so there is no reason to
|
|
compute it again every frame.
|
|
|
|
The `- 1` in the `overlayBox` line is not a typo. `overlayBox` draws through
|
|
both corners you give it, so a box from `playerX` to `playerX + 40` is
|
|
forty-one pixels wide. Subtracting one makes the bar you see exactly forty
|
|
wide, which will matter in stage four when something has to decide whether it
|
|
touched you.
|
|
|
|
=== Stage Two: Something to Dodge
|
|
|
|
One block, falling. Add two constants to the list at the top:
|
|
|
|
[source,lua]
|
|
----
|
|
local BLOCK_SIZE = 12
|
|
local BLOCK_SPEED = 1.5
|
|
----
|
|
|
|
Two more variables under `goingRight`:
|
|
|
|
[source,lua]
|
|
----
|
|
local blockX = math.random(0, screenWidth - BLOCK_SIZE)
|
|
local blockY = -BLOCK_SIZE
|
|
----
|
|
|
|
In `onOverlayUpdate`, just above the `overlayClear()` line, make it fall:
|
|
|
|
[source,lua]
|
|
----
|
|
blockY = blockY + BLOCK_SPEED
|
|
if blockY > screenHeight then
|
|
blockX = math.random(0, screenWidth - BLOCK_SIZE)
|
|
blockY = -BLOCK_SIZE
|
|
end
|
|
----
|
|
|
|
And under the line that draws the player, draw it:
|
|
|
|
[source,lua]
|
|
----
|
|
colorForeground(255, 90, 90)
|
|
overlayBox(blockX, blockY, blockX + BLOCK_SIZE - 1, blockY + BLOCK_SIZE - 1)
|
|
----
|
|
|
|
Save. A red square falls, reaches the bottom, and reappears at the top in a
|
|
new place.
|
|
|
|
It starts at `-BLOCK_SIZE`, which is above the top edge of the screen. There
|
|
is nothing wrong with a negative position: the engine draws the part of the
|
|
box that is on the overlay and quietly throws away the part that is not. The
|
|
block slides into view instead of popping into existence.
|
|
|
|
`BLOCK_SPEED` is `1.5`, not a whole number, so `blockY` spends most of its
|
|
life being something like `83.5`. That is fine too. Drawing chops the fraction
|
|
off, and keeping it in the variable is what lets the block move slower than
|
|
one pixel a frame.
|
|
|
|
=== Stage Three: A Sky Full of Them
|
|
|
|
One block is not a game. In lesson six you learned to keep many of something
|
|
in a list, and this is what it was for.
|
|
|
|
Delete `blockX` and `blockY`, and put these in their place:
|
|
|
|
[source,lua]
|
|
----
|
|
local blocks = {}
|
|
local spawnTimer = 0
|
|
----
|
|
|
|
Add one more constant at the top:
|
|
|
|
[source,lua]
|
|
----
|
|
local SPAWN_FRAMES = 40
|
|
----
|
|
|
|
Now a function that makes one block and puts it in the list. Put it above
|
|
`onInputPressed`:
|
|
|
|
[source,lua]
|
|
----
|
|
function spawnBlock()
|
|
local block = {}
|
|
block.x = math.random(0, screenWidth - BLOCK_SIZE)
|
|
block.y = -BLOCK_SIZE
|
|
table.insert(blocks, block)
|
|
end
|
|
----
|
|
|
|
Replace the falling code from stage two with this:
|
|
|
|
[source,lua]
|
|
----
|
|
spawnTimer = spawnTimer - 1
|
|
if spawnTimer <= 0 then
|
|
spawnBlock()
|
|
spawnTimer = SPAWN_FRAMES
|
|
end
|
|
|
|
for i = #blocks, 1, -1 do
|
|
local block = blocks[i]
|
|
block.y = block.y + BLOCK_SPEED
|
|
if block.y > screenHeight then
|
|
table.remove(blocks, i)
|
|
end
|
|
end
|
|
----
|
|
|
|
And replace the one line that drew the block with a loop over all of them:
|
|
|
|
[source,lua]
|
|
----
|
|
for i, block in ipairs(blocks) do
|
|
overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1)
|
|
end
|
|
----
|
|
|
|
Save. Blocks rain down.
|
|
|
|
A block is a record: one little table with an `x` and a `y` in it, which is
|
|
how you keep two numbers that belong together from drifting apart. `blocks` is
|
|
a list of those records, which is the shape almost every game's world has.
|
|
|
|
`spawnTimer` counts down one per frame, and when it reaches zero a block is
|
|
born and the timer is wound back up. Forty frames is about two thirds of a
|
|
second. There is no clock in this game and there does not need to be one:
|
|
`onOverlayUpdate` is the heartbeat, and counting heartbeats is a perfectly
|
|
good way to measure time.
|
|
|
|
The falling loop runs *backwards* -- from `#blocks` down to `1`, a step at a
|
|
time -- because it removes things as it goes. That is the rule from lesson
|
|
six, and this is the first time you have had a real reason to obey it. Remove
|
|
item three while walking forwards and everything shuffles down one, so the
|
|
block that was item four is now item three and the loop never looks at it.
|
|
Walking backwards, everything that moves has already been dealt with.
|
|
|
|
The drawing loop uses `ipairs` instead, because it removes nothing and only
|
|
wants each block in turn.
|
|
|
|
=== Stage Four: Getting Hit
|
|
|
|
The blocks fall straight through you. Time to notice.
|
|
|
|
The engine has a function for exactly this question. Change the test inside
|
|
the falling loop from this:
|
|
|
|
[source,lua]
|
|
----
|
|
if block.y > screenHeight then
|
|
table.remove(blocks, i)
|
|
end
|
|
----
|
|
|
|
to this:
|
|
|
|
[source,lua]
|
|
----
|
|
if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) or block.y > screenHeight then
|
|
table.remove(blocks, i)
|
|
end
|
|
----
|
|
|
|
Save, and drive the bar into a falling block. It vanishes.
|
|
|
|
`collideRects` takes two rectangles and answers `true` if they overlap. Each
|
|
rectangle is four numbers: a corner, then a width and a height -- *not* two
|
|
corners, which is the other reasonable way to describe a rectangle and the way
|
|
`overlayBox` does it. The manual's entry for `collideRects` gives the order,
|
|
and it is worth looking at now, because getting the eight arguments in the
|
|
wrong order is the kind of mistake that produces no error at all, just a game
|
|
where nothing ever hits anything.
|
|
|
|
You could write this yourself. It is four comparisons, and it is genuinely not
|
|
hard. Use the engine's anyway, for three reasons. It is right, including the
|
|
awkward cases: a rectangle with no width, two rectangles that only touch along
|
|
an edge. It says what it means, so the line reads as "did these two overlap"
|
|
instead of four `and`s you have to decode every time you come back to it. And
|
|
when you reach lesson eleven, where things hit each other constantly, you will
|
|
already know the family: `collidePointRect` for a mouse click, `collideCircles`
|
|
for two round things, and `collideSegments` for a shot against a wall. The manual
|
|
lists them together.
|
|
|
|
The one thing the engine cannot do for you is decide what a rectangle *means*.
|
|
The block's rectangle here is the whole block. In a real game you often want
|
|
the hit rectangle to be a little smaller than the picture, so a near miss
|
|
feels like a near miss. That is a design decision, and lesson eleven is where
|
|
you make it.
|
|
|
|
=== Stage Five: Score and Lives
|
|
|
|
Getting hit should cost something, and surviving should be worth something.
|
|
|
|
Three more constants at the top:
|
|
|
|
[source,lua]
|
|
----
|
|
local BLOCK_FASTER = 0.05
|
|
local START_LIVES = 3
|
|
----
|
|
|
|
Three more variables, next to `spawnTimer`:
|
|
|
|
[source,lua]
|
|
----
|
|
local blockSpeed = BLOCK_SPEED
|
|
local score = 0
|
|
local lives = START_LIVES
|
|
----
|
|
|
|
`BLOCK_SPEED` is now the speed the game *starts* at, and `blockSpeed` is the
|
|
speed it is going at right now. Change the falling line to use the variable:
|
|
|
|
[source,lua]
|
|
----
|
|
block.y = block.y + blockSpeed
|
|
----
|
|
|
|
Then split the two cases apart, because they now do different things:
|
|
|
|
[source,lua]
|
|
----
|
|
if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then
|
|
table.remove(blocks, i)
|
|
lives = lives - 1
|
|
elseif block.y > screenHeight then
|
|
table.remove(blocks, i)
|
|
score = score + 1
|
|
blockSpeed = blockSpeed + BLOCK_FASTER
|
|
end
|
|
----
|
|
|
|
And show the numbers. Put this just before the `return`:
|
|
|
|
[source,lua]
|
|
----
|
|
overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives)
|
|
----
|
|
|
|
Save, and play it for a minute. It gets harder, which is the whole trick:
|
|
every block you dodge makes the next ones fall a twentieth of a pixel per
|
|
frame faster. Twenty points in and they are noticeably quicker. You did not
|
|
have to write a single word about difficulty levels.
|
|
|
|
You will also watch `LIVES` go to `-4`, because nothing stops the game yet.
|
|
That is the next stage, and it is the one that matters most.
|
|
|
|
=== Stage Six: Game Over, and Going Again
|
|
|
|
Here is the trap. The obvious way to add a game over screen is a variable
|
|
called `gameOver`, set to `true` when the lives run out. Then you want a title
|
|
screen before the first game, so you add `started`. Then you want a pause key,
|
|
so you add `paused`. Now you have three true-or-false variables, eight
|
|
combinations between them, and five of those combinations are nonsense that
|
|
your code has to be careful never to produce. Every beginner writes this game
|
|
at least once, and it is where small games go to die.
|
|
|
|
Do this instead. One variable holds *what the game is doing*, as a word:
|
|
|
|
[source,lua]
|
|
----
|
|
local state = "waiting"
|
|
----
|
|
|
|
It is `"waiting"` before the first game, `"playing"` during one, and `"over"`
|
|
when the lives are gone. Three states, one variable, and no such thing as an
|
|
impossible combination. There is nothing magic about the strings; they are
|
|
words you chose, and you must spell them the same way every time.
|
|
|
|
Now the function that begins a game. Every variable a game owns goes back to
|
|
its starting value here, in one place:
|
|
|
|
[source,lua]
|
|
----
|
|
function startGame()
|
|
playerX = (screenWidth - PLAYER_WIDTH) / 2
|
|
blocks = {}
|
|
blockSpeed = BLOCK_SPEED
|
|
spawnTimer = 0
|
|
score = 0
|
|
lives = START_LIVES
|
|
state = "playing"
|
|
end
|
|
----
|
|
|
|
Losing the last life ends it. Inside the collision branch, under
|
|
`lives = lives - 1`:
|
|
|
|
[source,lua]
|
|
----
|
|
if lives == 0 then
|
|
state = "over"
|
|
end
|
|
----
|
|
|
|
The space bar starts a game, but only when one is not already running. Add an
|
|
arm to `onInputPressed`:
|
|
|
|
[source,lua]
|
|
----
|
|
elseif what == SWITCH_BUTTON1 and state ~= "playing" then
|
|
startGame()
|
|
----
|
|
|
|
`~=` is "is not equal to". `state ~= "playing"` is true while waiting and true
|
|
while over, which are exactly the two moments when space should start a game.
|
|
`SWITCH_BUTTON1` is the space bar by default, and also the A button on a
|
|
controller.
|
|
|
|
Two small functions put the words on screen. `printCentered` does the
|
|
arithmetic once so that the three screens do not each do it badly:
|
|
|
|
[source,lua]
|
|
----
|
|
function drawOverText()
|
|
printCentered(7, "GAME OVER")
|
|
printCentered(9, "PRESS SPACE TO PLAY AGAIN")
|
|
end
|
|
|
|
|
|
function drawWaitingText()
|
|
printCentered(6, "DODGE THE BLOCKS")
|
|
printCentered(8, "ARROW KEYS TO MOVE")
|
|
printCentered(10, "PRESS SPACE TO START")
|
|
end
|
|
|
|
|
|
function printCentered(row, text)
|
|
overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text)
|
|
end
|
|
----
|
|
|
|
with one more constant at the top:
|
|
|
|
[source,lua]
|
|
----
|
|
local TEXT_COLUMNS = overlayGetWidth() // overlayGetFontWidth()
|
|
----
|
|
|
|
Finally, the important part. Move everything that draws out of
|
|
`onOverlayUpdate` into a function called `drawGame`, move everything that
|
|
moves into a function called `updatePlaying` -- both of them are in the full
|
|
listing below -- and leave `onOverlayUpdate` looking like this:
|
|
|
|
[source,lua]
|
|
----
|
|
function onOverlayUpdate()
|
|
if state == "playing" then
|
|
updatePlaying()
|
|
end
|
|
|
|
overlayClear()
|
|
drawGame()
|
|
if state == "waiting" then
|
|
drawWaitingText()
|
|
elseif state == "over" then
|
|
drawOverText()
|
|
end
|
|
|
|
return OVERLAY_UPDATED
|
|
end
|
|
----
|
|
|
|
Save. You have a game: a title screen, a game, a game over, and a way back
|
|
round.
|
|
|
|
Read those thirteen lines again, because they are the shape of every game you
|
|
will ever write. The world only moves while the state is `"playing"`, so the
|
|
game over screen freezes with the last blocks hanging exactly where they were.
|
|
The world is drawn every frame whatever the state, because the picture is the
|
|
same in all three; the state decides only what words go on top of it. And
|
|
there is precisely one place that makes that decision.
|
|
|
|
That is what you bought with the `state` variable. When you add a pause, you
|
|
add one arm to that `if` and one line to `onInputPressed`, and nothing else in
|
|
the game needs to know a thing about it.
|
|
|
|
=== The Whole Thing
|
|
|
|
Here it is, finished. If yours does not match, the difference is worth
|
|
finding.
|
|
|
|
[source,lua]
|
|
----
|
|
dofile("Singe/Framework.singe")
|
|
|
|
local PLAYER_WIDTH = 40
|
|
local PLAYER_HEIGHT = 8
|
|
local PLAYER_SPEED = 4
|
|
local PLAYER_MARGIN = 4
|
|
local BLOCK_SIZE = 12
|
|
local BLOCK_SPEED = 1.5
|
|
local BLOCK_FASTER = 0.05
|
|
local SPAWN_FRAMES = 40
|
|
local START_LIVES = 3
|
|
local TEXT_COLUMNS = overlayGetWidth() // overlayGetFontWidth()
|
|
|
|
local screenWidth = overlayGetWidth()
|
|
local screenHeight = overlayGetHeight()
|
|
local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN
|
|
|
|
local playerX = (screenWidth - PLAYER_WIDTH) / 2
|
|
local goingLeft = false
|
|
local goingRight = false
|
|
local blocks = {}
|
|
local blockSpeed = BLOCK_SPEED
|
|
local spawnTimer = 0
|
|
local score = 0
|
|
local lives = START_LIVES
|
|
local state = "waiting"
|
|
|
|
|
|
function drawGame()
|
|
colorForeground(80, 255, 160)
|
|
overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1)
|
|
colorForeground(255, 90, 90)
|
|
for i, block in ipairs(blocks) do
|
|
overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1)
|
|
end
|
|
overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives)
|
|
end
|
|
|
|
|
|
function drawOverText()
|
|
printCentered(7, "GAME OVER")
|
|
printCentered(9, "PRESS SPACE TO PLAY AGAIN")
|
|
end
|
|
|
|
|
|
function drawWaitingText()
|
|
printCentered(6, "DODGE THE BLOCKS")
|
|
printCentered(8, "ARROW KEYS TO MOVE")
|
|
printCentered(10, "PRESS SPACE TO START")
|
|
end
|
|
|
|
|
|
function printCentered(row, text)
|
|
overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text)
|
|
end
|
|
|
|
|
|
function spawnBlock()
|
|
local block = {}
|
|
block.x = math.random(0, screenWidth - BLOCK_SIZE)
|
|
block.y = -BLOCK_SIZE
|
|
table.insert(blocks, block)
|
|
end
|
|
|
|
|
|
function startGame()
|
|
playerX = (screenWidth - PLAYER_WIDTH) / 2
|
|
blocks = {}
|
|
blockSpeed = BLOCK_SPEED
|
|
spawnTimer = 0
|
|
score = 0
|
|
lives = START_LIVES
|
|
state = "playing"
|
|
end
|
|
|
|
|
|
function updatePlaying()
|
|
if goingLeft then
|
|
playerX = playerX - PLAYER_SPEED
|
|
end
|
|
if goingRight then
|
|
playerX = playerX + PLAYER_SPEED
|
|
end
|
|
if playerX < 0 then
|
|
playerX = 0
|
|
elseif playerX > screenWidth - PLAYER_WIDTH then
|
|
playerX = screenWidth - PLAYER_WIDTH
|
|
end
|
|
|
|
spawnTimer = spawnTimer - 1
|
|
if spawnTimer <= 0 then
|
|
spawnBlock()
|
|
spawnTimer = SPAWN_FRAMES
|
|
end
|
|
|
|
for i = #blocks, 1, -1 do
|
|
local block = blocks[i]
|
|
block.y = block.y + blockSpeed
|
|
if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then
|
|
table.remove(blocks, i)
|
|
lives = lives - 1
|
|
if lives == 0 then
|
|
state = "over"
|
|
end
|
|
elseif block.y > screenHeight then
|
|
table.remove(blocks, i)
|
|
score = score + 1
|
|
blockSpeed = blockSpeed + BLOCK_FASTER
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
function onInputPressed(what)
|
|
if what == SWITCH_LEFT then
|
|
goingLeft = true
|
|
elseif what == SWITCH_RIGHT then
|
|
goingRight = true
|
|
elseif what == SWITCH_BUTTON1 and state ~= "playing" then
|
|
startGame()
|
|
end
|
|
end
|
|
|
|
|
|
function onInputReleased(what)
|
|
if what == SWITCH_LEFT then
|
|
goingLeft = false
|
|
elseif what == SWITCH_RIGHT then
|
|
goingRight = false
|
|
end
|
|
end
|
|
|
|
|
|
function onOverlayUpdate()
|
|
if state == "playing" then
|
|
updatePlaying()
|
|
end
|
|
|
|
overlayClear()
|
|
drawGame()
|
|
if state == "waiting" then
|
|
drawWaitingText()
|
|
elseif state == "over" then
|
|
drawOverText()
|
|
end
|
|
|
|
return OVERLAY_UPDATED
|
|
end
|
|
----
|
|
|
|
About a hundred and twenty lines of code, and you wrote all of them a handful
|
|
at a time.
|
|
|
|
=== What Just Happened
|
|
|
|
A few pieces deserve a second look.
|
|
|
|
[source,lua]
|
|
----
|
|
local blocks = {}
|
|
local score = 0
|
|
|
|
|
|
function startGame()
|
|
score = 0
|
|
end
|
|
----
|
|
|
|
The data is `local` and the functions are not. Those are two deliberate and
|
|
opposite choices.
|
|
|
|
The data is `local` so that it belongs to this file and nothing outside can
|
|
reach in and change it. The functions are global so that they can call each
|
|
other in any order: `drawGame` is defined before `printCentered` and calls it
|
|
anyway, because a global name is looked up at the moment of the call, by which
|
|
time the whole file has been read. Make those functions `local` and the file
|
|
has to be sorted so that nothing is used before it appears, which is a rule
|
|
you would spend the rest of the book tripping over. Three of the names here
|
|
have no choice at all: `onOverlayUpdate` and the two input callbacks must be
|
|
global, because that is how Singe finds them.
|
|
|
|
Inside the file, the order is alphabetical, with the three callbacks last.
|
|
That is not a rule of Lua. It is so that in six months you can find
|
|
`spawnBlock` without reading anything.
|
|
|
|
[source,lua]
|
|
----
|
|
overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text)
|
|
----
|
|
|
|
`+#text+` is the length of a string, in characters. You met `+#+` in lesson six
|
|
counting the items in a list, and it does the same job here. `"GAME OVER"` is
|
|
nine characters, so on a sixty column screen it starts at column twenty-five,
|
|
which leaves twenty-five cells of space on its left and twenty-six on its
|
|
right.
|
|
`math.floor` throws away the fraction, because there is no such thing as
|
|
column twenty-five and a half.
|
|
|
|
`TEXT_COLUMNS` works out how many character cells fit across the screen.
|
|
`overlayPrint` counts in cells while everything else counts in pixels, so the
|
|
width of the overlay is divided by the width of one cell, and
|
|
`overlayGetFontWidth` is what tells you that. Two slashes rather than one,
|
|
because a fraction of a column is not a place to print. Work it out this way
|
|
and the centring stays right at any overlay size, which is the fourth thing in
|
|
*Try it*.
|
|
|
|
[source,lua]
|
|
----
|
|
colorForeground(255, 90, 90)
|
|
----
|
|
|
|
`colorForeground` sets a colour that stays set. It is not an argument to
|
|
`overlayBox`; it is a switch you flip, and everything drawn afterwards comes
|
|
out in that colour until you flip it again. That is why `drawGame` sets green,
|
|
draws the player, sets red, and then draws all the blocks without mentioning
|
|
colour again.
|
|
|
|
It does not affect `overlayPrint`. The console font is copied onto the overlay
|
|
exactly as it is, in its own colours, which the manual's entry for
|
|
`overlayPrint` says in as many words. Coloured text needs a loaded font, and
|
|
that is lesson nineteen.
|
|
|
|
=== Try It
|
|
|
|
. *Make it easier, then unfair.* Change `PLAYER_WIDTH` to `80` and play a
|
|
round. Then change it to `12`, the same size as a block. Notice that you did
|
|
not have to touch the drawing or the collision to do either one.
|
|
. *Speed up faster.* Change `BLOCK_FASTER` from `0.05` to `0.5` and see how
|
|
many points you can get. Then try `0`, and ask yourself whether the game is
|
|
still worth playing.
|
|
. *An extra life.* Give the player a life back every ten points. Everything
|
|
you need is already in `updatePlaying`, in the branch that adds to the
|
|
score.
|
|
. *Play it bigger.* Run it with `Singe -R --canvas=1280x720 dodge`. The
|
|
game lays itself out correctly, because it asked the engine how big the
|
|
screen was -- except for the centred text. Work out why, and fix it.
|
|
. *Pause it.* Add a fourth state, `"paused"`. `SWITCH_BUTTON2` should swap
|
|
between `"playing"` and `"paused"`, and nothing should move while paused.
|
|
Done right, this is three lines and you do not have to touch `updatePlaying`
|
|
at all.
|
|
|
|
=== Break It on Purpose
|
|
|
|
Go into `spawnBlock` and delete the line that sets `block.y`, so that it reads:
|
|
|
|
[source,lua]
|
|
----
|
|
function spawnBlock()
|
|
local block = {}
|
|
block.x = math.random(0, screenWidth - BLOCK_SIZE)
|
|
table.insert(blocks, block)
|
|
end
|
|
----
|
|
|
|
Save, press space, and Singe stops with this:
|
|
|
|
----
|
|
Error executing function 'onOverlayUpdate': dodge.singe:97: attempt to perform arithmetic on a nil value (field 'y')
|
|
stack traceback:
|
|
dodge.singe:97: in function 'updatePlaying'
|
|
dodge.singe:135: in function 'onOverlayUpdate'
|
|
----
|
|
|
|
Line 97 is `block.y = block.y + blockSpeed`. The complaint is that something in
|
|
that sum is not a number, and the part in parentheses tells you which one:
|
|
`field 'y'`, meaning the `y` inside a table. It has no value, because you
|
|
never gave it one, and a thing with no value in Lua is `nil`.
|
|
|
|
Notice where the error is and where the mistake is. The error is in
|
|
`updatePlaying`, forty lines away from `spawnBlock`, where nothing is wrong at
|
|
all. `updatePlaying` is only the first piece of code unlucky enough to touch
|
|
the damage. This is the normal case, not the exception, and following that
|
|
trail backwards is a skill.
|
|
|
|
Put the line back. Then turn the page, because the next lesson is about
|
|
nothing else.
|
|
|
|
=== What You Learned
|
|
|
|
* A game is a world that changes, a world that gets drawn, and a decision
|
|
about which of those to do -- and those are three separate pieces of code.
|
|
* Input handlers should record what is held down; the update should decide
|
|
what that means.
|
|
* A list of records is the shape of almost every game world.
|
|
* Remove things from a list by walking it backwards.
|
|
* `collideRects` answers whether two rectangles overlap, and the engine's
|
|
version is better than yours because it is right, it reads well, and it has
|
|
a family you will meet again.
|
|
* One `state` variable holding a word beats three true-or-false flags, and
|
|
keeps beating them as the game grows.
|
|
* Start a game from one function that resets everything, so that the second
|
|
game is exactly like the first.
|
|
* Numbers you might want to tune belong in named constants at the top of the
|
|
file.
|
|
* Build a game in stages that each run. A program that has never worked is
|
|
much harder to fix than one that worked five minutes ago.
|
|
|
|
=== Next Time
|
|
|
|
You have now written enough code to make interesting mistakes. The next lesson
|
|
is the one no beginner's book bothers with: what Singe's error messages
|
|
actually say, how to find out what a variable really holds instead of guessing,
|
|
and how to cut a problem in half until there is nowhere left for it to hide.
|