480 lines
16 KiB
Text
480 lines
16 KiB
Text
== Lesson 12: Score, Lives, and Game Over
|
|
|
|
image::learn/12-score.png[The finished lesson, 480]
|
|
|
|
What you have is a toy: it starts in the middle of the action, it never ends,
|
|
and nothing you do in it counts. What separates a toy from a game is that the
|
|
game keeps score, takes something away when you fail, stops, and lets you try
|
|
again.
|
|
|
|
This lesson adds all of that, and then adds the one thing that makes a player
|
|
come back tomorrow: a high score that is still there when they do.
|
|
|
|
=== A Game Is Always in One of Three States
|
|
|
|
Watch any arcade machine for two minutes. It shows a title and waits for
|
|
somebody. Somebody presses start and it plays. The player runs out of lives and
|
|
it says so, then goes back to waiting. Three states, and the machine is always
|
|
in exactly one of them.
|
|
|
|
Your script needs a variable for which one it is in:
|
|
|
|
[source,lua]
|
|
----
|
|
local TITLE = "title"
|
|
local PLAYING = "playing"
|
|
local OVER = "over"
|
|
|
|
local state = TITLE
|
|
----
|
|
|
|
`state` holds a string, and the three constants hold the only three strings it
|
|
is allowed to hold. You could write `state = "playing"` everywhere instead and
|
|
the program would run exactly the same -- until the day you type
|
|
`state = "palying"`. That is a working line of Lua and the game silently stops
|
|
doing anything, because no test anywhere matches it. Typing `PLAYING` wrong
|
|
gets you `nil` instead, which goes wrong loudly and at once. Spend three lines,
|
|
buy yourself an error message.
|
|
|
|
Lesson seven had an `if` that decided what the game did. This is the same idea,
|
|
grown up and given a name: a *state machine*. It is worth the fancy phrase
|
|
because almost every game ever written has one somewhere.
|
|
|
|
=== One Place That Decides
|
|
|
|
The rule that makes states worth having is that there is exactly one place that
|
|
looks at `state` and decides what happens. Put it in `onOverlayUpdate`, and
|
|
give each state a function of its own:
|
|
|
|
[source,lua]
|
|
----
|
|
function onOverlayUpdate()
|
|
overlayClear()
|
|
|
|
if state == TITLE then
|
|
updateTitle()
|
|
elseif state == PLAYING then
|
|
updatePlaying()
|
|
else
|
|
updateOver()
|
|
end
|
|
|
|
return OVERLAY_UPDATED
|
|
end
|
|
----
|
|
|
|
Everything the game was doing every frame -- moving the ship, the rocks, and
|
|
the shots, checking hits, drawing all of it -- moves inside `updatePlaying`:
|
|
|
|
[source,lua]
|
|
----
|
|
local function updatePlaying()
|
|
moveShip()
|
|
moveRocks()
|
|
moveShots()
|
|
checkHits()
|
|
drawWorld()
|
|
drawScore()
|
|
end
|
|
----
|
|
|
|
`drawWorld` is the drawing you already had, lifted out of `onOverlayUpdate` and
|
|
given a name:
|
|
|
|
[source,lua]
|
|
----
|
|
local function drawWorld()
|
|
spriteDraw(shipSprite, shipX, shipY)
|
|
for _, rock in ipairs(rocks) do
|
|
spriteDraw(rockSprite, rock.x, rock.y)
|
|
end
|
|
for _, shot in ipairs(shots) do
|
|
spriteDraw(shotSprite, shot.x, shot.y)
|
|
end
|
|
end
|
|
----
|
|
|
|
The other two states are quieter, because nothing moves in them:
|
|
|
|
[source,lua]
|
|
----
|
|
local function updateTitle()
|
|
overlayPrint(25, 5, "R O C K S")
|
|
overlayPrint(24, 8, "BEST " .. best)
|
|
overlayPrint(21, 11, "PRESS FIRE TO PLAY")
|
|
end
|
|
|
|
|
|
local function updateOver()
|
|
drawWorld()
|
|
drawScore()
|
|
overlayPrint(25, 8, "GAME OVER")
|
|
overlayPrint(21, 11, "PRESS FIRE TO PLAY")
|
|
end
|
|
----
|
|
|
|
`updateOver` draws the world again so the wreck stays on screen under the
|
|
words, frozen, because nothing is moving it any more. You get that for free by
|
|
not calling the movement functions. Stopping a game is not a special feature;
|
|
it is leaving things out.
|
|
|
|
The input callback needs the same single decision. While you are playing, left,
|
|
right, and fire mean what they meant in lesson eleven. In the other two states
|
|
there is only one thing a button can mean:
|
|
|
|
[source,lua]
|
|
----
|
|
function onInputPressed(what)
|
|
if state == PLAYING then
|
|
if what == SWITCH_LEFT then
|
|
movingLeft = true
|
|
elseif what == SWITCH_RIGHT then
|
|
movingRight = true
|
|
elseif what == SWITCH_BUTTON1 then
|
|
fireShot()
|
|
end
|
|
elseif what == SWITCH_BUTTON1 or what == SWITCH_START1 then
|
|
startGame()
|
|
end
|
|
end
|
|
----
|
|
|
|
Two states are handled by one branch there, because "press anything to play" is
|
|
the same answer on the title screen and on the game over screen. When they stop
|
|
being the same -- a game over screen that waits three seconds first, say -- you
|
|
will split them, and the shape of the code will tell you where to cut.
|
|
|
|
=== Score and Lives
|
|
|
|
Two numbers and three constants:
|
|
|
|
[source,lua]
|
|
----
|
|
local ROCK_POINTS = 10
|
|
local START_LIVES = 3
|
|
|
|
local score = 0
|
|
local lives = START_LIVES
|
|
----
|
|
|
|
They change in `checkHits`, where the game already knows what touched what. A
|
|
destroyed rock is worth points, so add the middle line of these three to the
|
|
shot-against-rock loop you wrote in lesson eleven:
|
|
|
|
[source,lua]
|
|
----
|
|
soundPlay(boomSound)
|
|
score = score + ROCK_POINTS
|
|
break
|
|
----
|
|
|
|
And a rock that reaches the ship costs a life, and possibly the game:
|
|
|
|
[source,lua]
|
|
----
|
|
for r = #rocks, 1, -1 do
|
|
if shipHitsRock(rocks[r]) then
|
|
table.remove(rocks, r)
|
|
soundPlay(boomSound)
|
|
lives = lives - 1
|
|
if lives <= 0 then
|
|
endGame()
|
|
end
|
|
end
|
|
end
|
|
----
|
|
|
|
`lives <= 0` rather than `lives == 0` is a small piece of insurance. Today
|
|
nothing can take two lives at once, so they can only ever be equal. The day you
|
|
add a rock that costs two, `== 0` steps straight over zero and the player keeps
|
|
playing on minus one life, while `<= 0` keeps working. Ask for what you mean.
|
|
|
|
Show them both in a line across the top:
|
|
|
|
[source,lua]
|
|
----
|
|
local function drawScore()
|
|
overlayPrint(1, 1, "SCORE " .. score)
|
|
overlayPrint(25, 1, "BEST " .. best)
|
|
overlayPrint(48, 1, "LIVES " .. lives)
|
|
end
|
|
----
|
|
|
|
Those numbers are character cells, not pixels, as they were in lesson one. The
|
|
overlay is 360 by 240 unless you change it, and a cell of the console font is
|
|
six pixels wide, so a line holds sixty of them.
|
|
|
|
=== Starting Again
|
|
|
|
Going from the title screen to playing is not one line. Everything the last
|
|
game left behind has to be put back:
|
|
|
|
[source,lua]
|
|
----
|
|
local function startGame()
|
|
score = 0
|
|
lives = START_LIVES
|
|
rocks = {}
|
|
shots = {}
|
|
shipX = (overlayGetWidth() - SHIP_WIDTH) / 2
|
|
movingLeft = false
|
|
movingRight = false
|
|
state = PLAYING
|
|
end
|
|
----
|
|
|
|
`rocks = {}` throws the old list away and puts an empty one in its place. The
|
|
two `false` lines matter more than they look: if the player was holding right
|
|
when the last rock hit, `movingRight` is still `true`, and the new game starts
|
|
with the ship pinned to the wall until they press and release the key again.
|
|
|
|
Bugs in this function are the most common bugs in any game, and they all feel
|
|
the same to play: the second game is subtly wrong and the first one was fine.
|
|
When that happens, read `startGame` and ask what the last game changed that
|
|
this one did not put back.
|
|
|
|
Ending a game is shorter, and it is where the interesting part of this lesson
|
|
lives:
|
|
|
|
[source,lua]
|
|
----
|
|
local function endGame()
|
|
if score > best then
|
|
best = score
|
|
saveSet("highScore", best)
|
|
saveFlush()
|
|
end
|
|
state = OVER
|
|
end
|
|
----
|
|
|
|
=== A Number That Outlives the Program
|
|
|
|
Everything your game has held so far -- `score`, `lives`, the list of rocks --
|
|
lives in memory, and memory is gone the instant the program stops. Quit the
|
|
game and the best score anyone ever got goes with it.
|
|
|
|
Singe gives every game a save of its own: a set of names with values under
|
|
them, written to disk for you.
|
|
|
|
[source,lua]
|
|
----
|
|
saveSet("highScore", 4200)
|
|
----
|
|
|
|
`saveSet` takes a name -- the *key* -- and a value. The key is yours to choose
|
|
and never changes; the value is whatever you want to remember. Numbers,
|
|
strings, `true` and `false`, and whole tables of those all go in. Things that
|
|
only make sense while the program is running do not: a sprite handle is a
|
|
number that means nothing tomorrow, and a function cannot be written down at
|
|
all.
|
|
|
|
Reading it back is the other half:
|
|
|
|
[source,lua]
|
|
----
|
|
local best = saveGet("highScore", 0)
|
|
----
|
|
|
|
That line goes near the top of your script, beside the other variables, and it
|
|
runs once when the game starts. The second argument is the *default*: what to
|
|
answer when nothing has ever been saved under that key. More about that in a
|
|
moment, because it is the whole of this lesson's error.
|
|
|
|
You do not have to ask Singe to write the file. It writes once at the end of
|
|
any frame in which something changed, however many keys you set, and again when
|
|
the game shuts down. `saveFlush` writes it immediately instead, which is what
|
|
`endGame` uses: a new high score is worth a file write of its own, because an
|
|
arcade cabinet gets switched off at the wall and a frame is a long time.
|
|
|
|
The manual's Save section lists the rest of the family. `saveDelete` forgets one
|
|
key, `saveClear` forgets everything -- that is the "reset high scores" in a
|
|
service menu -- and `saveGetAll` hands you a copy of the lot, which is useful
|
|
when you want to print the whole save while you are hunting something.
|
|
|
|
=== Where the File Actually Goes
|
|
|
|
It does not go beside your script, and this is not an accident.
|
|
|
|
A finished game may live on a disc, on a network share, or inside a single
|
|
packed file (lesson twenty-nine), and none of those can be written to. A game
|
|
that saves next to its own script works perfectly on the machine you wrote it
|
|
on and fails on half the machines it is installed on. Singe 2 did it that way
|
|
and it was wrong.
|
|
|
|
So everything Singe writes for a game -- the save, screenshots, its log --
|
|
goes in one place, and `singeGetDataPath` will tell you where:
|
|
|
|
[source,lua]
|
|
----
|
|
debugPrint(singeGetDataPath())
|
|
----
|
|
|
|
Put that at the top of your script for a moment and run it. You get a path
|
|
ending in a separator, under a `data` folder, and inside it you will find
|
|
`save.json`. Open it in your text editor: it is plain text, and your high score
|
|
is in there under the name you gave it.
|
|
|
|
That is the answer whenever you need a file of your own as well. Build the
|
|
name from `singeGetDataPath()` and it lands somewhere writable; write to a name
|
|
of your own invention and sooner or later it does not.
|
|
|
|
=== The First Time, There Is Nothing
|
|
|
|
The very first time anybody runs your game, no high score has ever been saved.
|
|
`saveGet` has nothing to hand back, so it hands back the default you gave it:
|
|
|
|
[source,lua]
|
|
----
|
|
local best = saveGet("highScore", 0)
|
|
----
|
|
|
|
Nothing saved, so `best` is `0`, the title screen says `BEST 0`, and the first
|
|
game anybody plays sets a record. That is exactly what you want, and it costs
|
|
one extra argument.
|
|
|
|
Leave the `0` out and `saveGet` has no default to fall back on, so it answers
|
|
`nil` -- the value Lua uses for "there is nothing here", which you met in lesson
|
|
eight. `nil` is not zero. You cannot add to it, you cannot compare it with a
|
|
number, and you cannot glue it onto a string. It goes wrong on the first frame,
|
|
every time, on every machine where the game has not been played -- which
|
|
includes every machine your players are about to install it on, and not the one
|
|
you are testing on.
|
|
|
|
Give every `saveGet` a default. It is the cheapest habit in this book.
|
|
|
|
=== What Just Happened
|
|
|
|
[source,lua]
|
|
----
|
|
local TITLE = "title"
|
|
----
|
|
|
|
A constant: a variable you set once and never change, with a name in capitals
|
|
so that you can see at a glance that it is one. Lua does not enforce that --
|
|
nothing stops you assigning to `TITLE` -- so the capitals are a message to the
|
|
next person reading, who is you in a fortnight.
|
|
|
|
[source,lua]
|
|
----
|
|
if state == TITLE then
|
|
updateTitle()
|
|
elseif state == PLAYING then
|
|
updatePlaying()
|
|
else
|
|
updateOver()
|
|
end
|
|
----
|
|
|
|
The one decision. Every frame goes through here, and nothing else in the game
|
|
asks what state it is in except `onInputPressed`. When you add a fourth state
|
|
later -- a pause, or a bonus round -- this is where it is added, and you will
|
|
know it is the only place.
|
|
|
|
The `else` on the end catches "any other state". With three states that is the
|
|
same as `elseif state == OVER`, and either is fine. What matters is that there
|
|
is no way for a frame to fall through all the branches and draw nothing.
|
|
|
|
[source,lua]
|
|
----
|
|
score = score + ROCK_POINTS
|
|
----
|
|
|
|
Not `score = score + 10`. A number with a meaning gets a name, once, at the top
|
|
of the file. When you decide a rock is worth twenty-five, you change one line,
|
|
and you change it without reading the collision code.
|
|
|
|
[source,lua]
|
|
----
|
|
saveSet("highScore", best)
|
|
saveFlush()
|
|
----
|
|
|
|
`saveSet` puts the number in the save; `saveFlush` puts the save on the disk
|
|
now rather than at the end of the frame. Both are inside the `if`, so the file
|
|
is only written when the record is actually broken, which is a few times a
|
|
session rather than sixty times a second.
|
|
|
|
[source,lua]
|
|
----
|
|
local best = saveGet("highScore", 0)
|
|
----
|
|
|
|
Read once, at startup, into an ordinary variable. The rest of the game reads
|
|
`best` rather than calling `saveGet` again, because the save is a file and a
|
|
variable is a variable. Write to both when the record falls.
|
|
|
|
=== Try It
|
|
|
|
. *Make it harder as it goes.* Multiply the rock speed, or the chance of a new
|
|
rock, by something that grows with the score. Two lines will do it.
|
|
. *Show the lives as ships.* Draw `shipSprite` once per life in the top right
|
|
corner with `spriteDraw` instead of printing the number. A `for` loop from
|
|
lesson four, and the ship is 32 wide.
|
|
. *Save more than a number.* Add `saveSet("gamesPlayed", saveGet("gamesPlayed",
|
|
0) + 1)` to `startGame`, and print it on the title screen. Quit, run again,
|
|
and watch it remember.
|
|
. *Find the file and break it.* Print `singeGetDataPath()`, open `save.json` in
|
|
your editor, change the high score to something absurd, and run the game.
|
|
Then delete the file entirely and run it again. Both of those will happen to
|
|
your players.
|
|
. *Reset it from inside the game.* Make a key you would never press by accident
|
|
call `saveClear()`, and check the title screen afterwards. Careful: `best` is
|
|
still in memory, and `saveClear` does not touch it. Work out what else has to
|
|
happen.
|
|
|
|
=== Break It on Purpose
|
|
|
|
Take the default out, so the line reads:
|
|
|
|
[source,lua]
|
|
----
|
|
local best = saveGet("highScore")
|
|
----
|
|
|
|
If you have already set a high score, nothing happens at all, which is the
|
|
trap: the save has the key, so `saveGet` hands it back. Delete `save.json`
|
|
first, then run it. The title screen never appears, and you get this, with
|
|
`rocks.singe` standing in for whatever you called your script:
|
|
|
|
----
|
|
Error executing function 'onOverlayUpdate': rocks.singe:190: attempt to concatenate a nil value (upvalue 'best')
|
|
stack traceback:
|
|
----
|
|
|
|
Read it the way lesson eight taught you. The file and the line take you to
|
|
`overlayPrint(24, 8, "BEST " .. best)`, and the complaint says something that
|
|
was `nil` was glued onto a string. `..` joins two strings, and `nil` is not
|
|
one.
|
|
|
|
The line it names is not where the mistake is. Line 190 is where the game
|
|
finally tripped over it; the mistake was made at the top of the file, where
|
|
`best` was asked for and nothing was there. That gap between where a bad value
|
|
is made and where it is noticed is the hardest thing about `nil`, and the
|
|
reason a default costs one argument and saves an evening.
|
|
|
|
Put the `0` back.
|
|
|
|
=== What You Learned
|
|
|
|
* A game is in exactly one state at a time: attract, playing, or over.
|
|
* Keep the state in one variable, with named constants for its values, and let
|
|
one `if` decide what a frame does.
|
|
* Ending the game is a matter of not calling the things that move it.
|
|
* `startGame` has to put back everything the last game changed, held keys
|
|
included.
|
|
* `saveSet` stores a value under a key, and `saveGet` reads it back on a later
|
|
run.
|
|
* `saveFlush` writes the file now, for the things you would hate to lose.
|
|
* The save goes in the game's own data directory, which `singeGetDataPath`
|
|
names. Never write beside your script.
|
|
* `saveGet` with no default answers `nil` when nothing was saved, and `nil`
|
|
breaks the first frame on a machine that has never run your game.
|
|
* A number with a meaning deserves a name at the top of the file.
|
|
|
|
=== Next Time
|
|
|
|
Your game is a game now, and it is a loose script in a folder you happen to
|
|
know the name of. Lesson thirteen turns it into something the engine can find
|
|
by itself, with a title, artwork, and an entry in the menu, so that a player
|
|
who has never heard of you can start it without typing anything.
|