singe/docs/lessons/05-functions.adoc
2026-09-22 21:57:42 -05:00

690 lines
22 KiB
Text

== Lesson 5: Your Own Functions
image::learn/05-functions.png[The finished lesson, 480]
Lesson four ended with a warning about your own program: `onOverlayUpdate`
works out where a hundred and twenty stars go, decides how bright each one is,
draws them, and counts the drift, and it is one function doing four jobs. It
is about to be asked to do a fifth, because a star field with nothing in front
of it is not a game.
In this lesson you take it apart. You will write functions of your own --
functions Singe has never heard of and will never call -- and hand the work to
them. By the end, the whole star field is one line that says `drawStars()`,
there is a ship back on the bottom of the screen, and the program is easier to
read than it was when it did less.
=== Start a New Folder
A folder called `functions`, a file in it called `functions.singe`, and
`Singe -R functions` running in a terminal.
Copy lesson four's finished program into it. This is it, unchanged, and it is
worth running once before you touch anything:
[source,lua]
----
LAYERS = 3
STARS_PER_LAYER = 40
ACROSS_STEP = 37
DOWN_STEP = 61
LAYER_SHIFT = 13
LAYER_SPEED = 0.5
LAYER_BRIGHT = 55
width = overlayGetWidth()
height = overlayGetHeight()
drift = 0
function onOverlayUpdate()
drift = drift + 1
overlayClear()
for layer = 1, LAYERS do
for star = 1, STARS_PER_LAYER do
starX = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width
starY = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height
shade = math.random(60, 90) + layer * LAYER_BRIGHT
colorForeground(shade, shade, shade)
overlayPlot(starX, starY)
end
end
return OVERLAY_UPDATED
end
----
Three layers of stars drifting down the window at three speeds. Nothing in the
first half of this lesson changes what you see. Everything in it changes how
the program reads.
=== One Job, One Name
Start with the two lines that put a star on the screen. Above
`onOverlayUpdate`, add this:
[source,lua]
----
function drawStar(x, y, shade)
colorForeground(shade, shade, shade)
overlayPlot(x, y)
end
----
Then, in the inner loop, delete those two lines and call it instead:
[source,lua]
----
for star = 1, STARS_PER_LAYER do
starX = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width
starY = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height
shade = math.random(60, 90) + layer * LAYER_BRIGHT
drawStar(starX, starY, shade)
end
----
Save. The picture is the same.
You have written functions before -- `onOverlayUpdate` is one, and so are
`onInputPressed` and `onInputReleased` from lesson three -- but those are
callbacks. You write them and Singe calls them. `drawStar` is different. Singe
has never heard of it. It runs because *you* wrote its name and parentheses,
the same way you call `overlayPlot`, and nothing in the world will run it
unless you do.
That is the whole trick. From here on, when you find yourself thinking "and
now draw a star", you can write "draw a star" and mean it.
=== A Parameter Is a Name, Not a Thing
Look at the first line of `drawStar` again.
[source,lua]
----
function drawStar(x, y, shade)
----
The three names in the parentheses are *parameters*. A parameter is a name the
function uses for whatever the caller handed it. When you write
`drawStar(starX, starY, shade)`, Lua sets `x` to whatever is in `starX`, `y`
to whatever is in `starY`, `shade` to whatever is in `shade`, and then runs
the function.
The names have nothing to do with each other. The caller's number is called
`starX` out there and `x` in here, and it would work exactly the same if you
called it `across` or `banana`. Inside the function, only the names in the
parentheses exist, and they hold copies of what came in.
This is where the empty parentheses from lesson one finally make sense.
[source,lua]
----
function onOverlayUpdate()
----
Nothing in the parentheses means no parameters: the engine has nothing to tell
this function, so there is nothing to name. Compare it with the callback you
wrote in lesson three.
[source,lua]
----
function onInputPressed(what)
----
One parameter, called `what`. The engine has something to tell this one --
which control was pressed -- so there is a name to catch it in. You have been
using a parameter since lesson three without knowing what it was called.
A parameter really is the function's own copy, and it is worth proving that to
yourself once. Type this in, call it from `onOverlayUpdate`, and take it out
again afterwards:
[source,lua]
----
function half(number)
number = number // 2
return number
end
----
Call it as `half(drift)`. The function halves its `number` and hands the
answer back, and `drift` out in `onOverlayUpdate` carries on counting as
though nothing had happened. A function cannot reach out and change the
caller's variables through a parameter. It gets a copy, and what it does to
the copy is its own business.
=== Handing an Answer Back
`half` did something the functions you have written so far did not: it gave an
answer. That is the last line.
[source,lua]
----
return number
----
`return` hands a value back to whoever called the function, and the call turns
into that value. Write `smaller = half(drift)` and Lua runs `half`, takes what
`half` returned, and puts it in `smaller`. You met `return` in lesson one,
where `onOverlayUpdate` returns `OVERLAY_UPDATED` to answer Singe's question
about whether anything changed. This is the same word doing the same job,
except that now you are the one asking the question.
The brightness line is begging to be a function with an answer. Add this one:
[source,lua]
----
function shadeFor(layer)
return math.random(60, 90) + layer * LAYER_BRIGHT
end
----
The whole body is a `return`, which is normal for a small function. Now the
inner loop can hand the answer straight to `drawStar` without a name in the
middle:
[source,lua]
----
drawStar(starX, starY, shadeFor(layer))
----
A call inside a call. Lua works the inside one out first, gets a number back
from `shadeFor`, and passes that number to `drawStar` as its third argument.
You have written something like it before without noticing: the
`math.floor(drift * layer * LAYER_SPEED)` in lesson four is a call whose
answer is used on the spot, in the middle of a bigger sum.
=== Two Answers at Once
The two position lines are harder, because they are two numbers worked out
together and you would have to pick one to hand back. In most languages a
function can return one value and getting two out of it is a nuisance. Lua
lets you return as many as you like.
[source,lua]
----
function starAt(star, layer)
local x = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width
local y = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height
return x, y
end
----
You catch the answers by putting two names on the left, separated by a comma:
[source,lua]
----
local x, y = starAt(star, layer)
----
First value into the first name, second into the second. If you ask for fewer
names than the function returns, the extra values are thrown away:
`local x = starAt(star, layer)` gives you the across number and loses the down
one, with no complaint. If you ask for more names than there are values, the
extras are `nil`.
Ignore the word `local` in those two blocks for one more minute. It has a
section of its own coming, and it is the important one.
=== The Star Field in One Line
The inner loop is now three short lines, all of them about one layer of stars.
That is a job with a name.
[source,lua]
----
function drawLayer(layer)
for star = 1, STARS_PER_LAYER do
local x, y = starAt(star, layer)
drawStar(x, y, shadeFor(layer))
end
end
----
And the outer loop is a job with a name too:
[source,lua]
----
function drawStars()
for layer = 1, LAYERS do
drawLayer(layer)
end
end
----
Functions calling functions calling functions, three deep, and each one is
three or four lines you can hold in your head at once. `onOverlayUpdate` can
now say what a frame *is*:
[source,lua]
----
function onOverlayUpdate()
drift = drift + 1
overlayClear()
drawStars()
return OVERLAY_UPDATED
end
----
That is the promise lesson four made at the end, kept.
=== Put the Ship Back
Now the fifth job, the one that would have made a mess of the old
`onOverlayUpdate`. Bring back the box you steered in lesson three, this time
as a ship sitting near the bottom of the screen that only moves left and
right.
It needs some values of its own, up at the top with the others, and lesson
three's two input callbacks with the up and down branches taken out:
[source,lua]
----
PLAYER_WIDTH = 20
PLAYER_HEIGHT = 6
PLAYER_SPEED = 3
PLAYER_MARGIN = 6
playerX = width // 2
playerY = height - PLAYER_HEIGHT - PLAYER_MARGIN
movingLeft = false
movingRight = false
----
[source,lua]
----
function onInputPressed(what)
if what == SWITCH_LEFT then
movingLeft = true
elseif what == SWITCH_RIGHT then
movingRight = true
end
end
function onInputReleased(what)
if what == SWITCH_LEFT then
movingLeft = false
elseif what == SWITCH_RIGHT then
movingRight = false
end
end
----
Drawing it is two lines, so it is a function:
[source,lua]
----
function drawPlayer()
colorForeground(80, 255, 120)
overlayBox(playerX, playerY, playerX + PLAYER_WIDTH, playerY + PLAYER_HEIGHT)
end
----
Moving it is where the lesson pays off. In lesson three, keeping the box on
the screen took four lines of `if` and `elseif` for each direction. That idea
-- hold a number between a bottom and a top -- has a name, and the name is
*clamp*:
[source,lua]
----
function clamp(value, low, high)
if value < low then
return low
end
if value > high then
return high
end
return value
end
----
Three returns, and at most one of them ever happens, because `return` does not
only hand back an answer: it stops the function dead. Nothing after it runs.
If the value is under the bottom, hand back the bottom and stop. If it is over
the top, hand back the top and stop. Otherwise hand back what came in.
[source,lua]
----
function movePlayer()
if movingLeft then
playerX = playerX - PLAYER_SPEED
end
if movingRight then
playerX = playerX + PLAYER_SPEED
end
playerX = clamp(playerX, 0, width - PLAYER_WIDTH - 1)
end
----
A function that ends without a `return` hands back nothing, which is `nil`.
That is right for `drawPlayer` and `movePlayer`, which are called to do
something rather than to answer something. It is a bug in a function like
`clamp`: leave off that last `return value` and every position in the middle
of the range comes back as `nil`.
=== Where Names Live
Every name you have made since lesson two has been a *global*. A global
belongs to the whole program: `drift`, `playerX`, and `LAYERS` can be read and
written by any function in the file, at any moment, and they stay there for as
long as the game runs.
That sounds convenient, and for six names it is. The trouble starts at sixty.
Every global is a name the whole program has agreed to reserve, so when some
other function wants somewhere to keep a position across the screen and calls
it `starX` as well, the two of them are the same box, and each writes over the
other's number. When a global holds something wrong, anything in the file
could have put it there, and finding out which means reading all of it.
A *local* is the cure. Put the word `local` in front of a name the first time
you use it, and the name exists only inside the `function`, the loop, or the
`if` it was written in.
[source,lua]
----
function starAt(star, layer)
local x = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width
local y = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height
return x, y
end
----
`x` and `y` here are nobody else's business. Another function can have its own
`x` and the two never meet. When `starAt`'s `x` is wrong, the three lines
above are the only three lines that could have done it. Without the word
`local`, those two lines would be quietly creating two more globals, which is
exactly what `starX` and `starY` have been all along.
Now the part that surprises everyone. Type this function in, and put a line in
`onOverlayUpdate` that shows you what it hands back:
[source,lua]
----
function countUp()
local counted = 0
counted = counted + 1
return counted
end
----
[source,lua]
----
overlayPrint(0, 0, "Count: " .. countUp())
----
`Count: 1`, and it stays `Count: 1` while the game runs, though that function
is called sixty times a second and adds one every time. A local is made fresh
when the function starts and is gone when the function ends, so `counted` is
born as `0`, becomes `1`, and dies, sixty times a second, forever. A local
does not remember. Take the two of them out again when you have looked at it.
That is not a flaw, it is the point: a local cannot be poisoned by the last
call, because there is nothing left of the last call. When you do need to
remember something between calls -- `drift` and `playerX` have to remember --
the name must live outside every function. Put it at the top of the file with
`local` in front of it:
[source,lua]
----
local drift = 0
local playerX = width // 2
----
A `local` written at the top of the file, outside all the functions, lives as
long as the script does and can be seen by every function below it. That last
word matters: *below*. A local exists from its own line downwards, so a
function written above the line that creates it cannot see it. The same is
true of the functions themselves once you mark them `local`, which is why the
finished script is in the order it is in: the values first, then your own
functions, then the callbacks, last.
Go through the whole file now and put `local` in front of every name you made
-- the constants in capitals, `width` and `height`, `drift`, the player's four
values, and every one of your own functions. Three names do not get it:
`onInputPressed`, `onInputReleased`, and `onOverlayUpdate` have to stay
global, because Singe finds them by name and a local name is invisible from
outside the file.
Be honest about what locals do not fix. If you mistype a name you are
assigning to -- `movinLeft = true` instead of `movingLeft = true` -- Lua does
not complain. It makes a brand new global with your typo for a name, sets it,
and leaves the real one alone. Nothing moves, nothing errors, and you stare at
it for ten minutes. Locals will not save you from that one. Reading the name
twice will.
=== The Whole Thing
Here is the finished program, with every piece of the lesson in it.
[source,lua]
----
local LAYERS = 3
local STARS_PER_LAYER = 40
local ACROSS_STEP = 37
local DOWN_STEP = 61
local LAYER_SHIFT = 13
local LAYER_SPEED = 0.5
local LAYER_BRIGHT = 55
local PLAYER_WIDTH = 20
local PLAYER_HEIGHT = 6
local PLAYER_SPEED = 3
local PLAYER_MARGIN = 6
local width = overlayGetWidth()
local height = overlayGetHeight()
local drift = 0
local playerX = width // 2
local playerY = height - PLAYER_HEIGHT - PLAYER_MARGIN
local movingLeft = false
local movingRight = false
local function clamp(value, low, high)
if value < low then
return low
end
if value > high then
return high
end
return value
end
local function shadeFor(layer)
return math.random(60, 90) + layer * LAYER_BRIGHT
end
local function starAt(star, layer)
local x = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width
local y = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height
return x, y
end
local function drawStar(x, y, shade)
colorForeground(shade, shade, shade)
overlayPlot(x, y)
end
local function drawLayer(layer)
for star = 1, STARS_PER_LAYER do
local x, y = starAt(star, layer)
drawStar(x, y, shadeFor(layer))
end
end
local function drawStars()
for layer = 1, LAYERS do
drawLayer(layer)
end
end
local function drawPlayer()
colorForeground(80, 255, 120)
overlayBox(playerX, playerY, playerX + PLAYER_WIDTH, playerY + PLAYER_HEIGHT)
end
local function movePlayer()
if movingLeft then
playerX = playerX - PLAYER_SPEED
end
if movingRight then
playerX = playerX + PLAYER_SPEED
end
playerX = clamp(playerX, 0, width - PLAYER_WIDTH - 1)
end
function onInputPressed(what)
if what == SWITCH_LEFT then
movingLeft = true
elseif what == SWITCH_RIGHT then
movingRight = true
end
end
function onInputReleased(what)
if what == SWITCH_LEFT then
movingLeft = false
elseif what == SWITCH_RIGHT then
movingRight = false
end
end
function onOverlayUpdate()
drift = drift + 1
overlayClear()
movePlayer()
drawStars()
drawPlayer()
return OVERLAY_UPDATED
end
----
Two things are worth pointing at before you move on.
`local function drawStar(x, y, shade)` marks the function itself as local.
Your own functions are names like any other, and they belong to this file
alone. The three callbacks stay global so that Singe can find them.
And `onOverlayUpdate` is now six lines that say what a frame is: count the
drift, clear the screen, move the player, draw the sky, draw the player, done.
It does five jobs by naming five things that do one job each, which is the
shape of every game loop you will ever write.
=== A Function Is a Name for an Idea
`clamp` is called once. Pulling it out saved no typing at all -- four lines
went in, one came out, and a nine line function appeared elsewhere. It was
still worth doing.
A function is not only a way to avoid repeating yourself. It is a way to give
a name to an idea, so that the code using the idea can say the name and move
on. "Keep the player between the left edge and the right edge" is a thought
you had once. `clamp(playerX, 0, width - PLAYER_WIDTH - 1)` is that thought,
written down. Four `if` statements are the same thought taken apart into
pieces you have to put back together in your head every time you read them.
When you are deciding whether something deserves to be a function, do not ask
how many times you will call it. Ask whether it has a name.
=== Try It
. *Rename a parameter.* In `drawStar`, change `x` and `y` to `across` and
`down`, everywhere inside the function. Do not touch anything outside it.
Run it. Work out why nothing broke.
. *Draw them the other way round.* Swap `drawStars()` and `drawPlayer()` in
`onOverlayUpdate` and look at the ship. Then put them back. Each of those
five lines is one job, and their order on the screen is their order in the
function.
. *Use `clamp` on a shade.* Change `shadeFor` to
`return clamp(math.random(60, 90) + layer * 90, 0, 200)` and look at the
three layers. Then take the `clamp` out and look again: `colorForeground`
was quietly holding the numbers down for you all along, and now you can see
where.
. *Call `drawStar` with two arguments.* Write `drawStar(100, 100)` somewhere
in `drawLayer` and run it. The error names the exact thing that went
missing. Read it before you put the third argument back.
. *Move a local.* Take the line `local drift = 0` and move it to the bottom of
the file, below everything. Run it, read the error, and think about the word
"below" in the section above.
=== Break It on Purpose
The order of a file with locals in it matters, and the error you get when you
break that order is not the one you expect.
Take the whole `drawStar` function and move it to the *bottom* of the script,
below `onOverlayUpdate`. Everything is still there, spelled the same. Run it:
----
Error executing function 'onOverlayUpdate': functions.singe:50: attempt to call a nil value (global 'drawStar')
stack traceback:
functions.singe:50: in upvalue 'drawLayer'
functions.singe:57: in upvalue 'drawStars'
functions.singe:101: in function 'onOverlayUpdate'
----
This looks different from the error in lesson one because it is a different
kind of error. Lesson one's mistake was caught before the script ran. This one
was caught while it was running, which is why Singe names the function it was
in the middle of, and why there is a *traceback* underneath: the list of who
called whom, newest first. Line 50 is inside `drawLayer`, which was called
from line 57 inside `drawStars`, which was called from line 101 inside
`onOverlayUpdate`. Your line numbers will differ if your file does not match
the one above exactly, and the engine may add a line or two of its own below
these. The word `upvalue` is Lua's name for a local that a function borrowed
from the file around it; read past it for now.
The complaint itself is worth learning by heart. "Attempt to call a nil value"
means you put parentheses after a name that held nothing. "Global `drawStar`"
is the part that gives the game away: you marked `drawStar` as local, so when
Lua read `drawLayer` and found no local of that name anywhere above it, the
only thing left to try was a global -- and there is no global called
`drawStar` either. Put the function back above the code that calls it and the
error goes with it.
=== What You Learned
* You can write functions of your own, and nothing runs them but your own
calls.
* The names inside a function's parentheses are parameters, and they are the
function's own copies of what the caller passed.
* `return` hands a value back and stops the function where it stands, and a
function that ends without one hands back `nil`.
* A call can be an argument to another call, and the inside one happens first.
* Lua can return more than one value, and you catch them with several names
separated by commas.
* `local` makes a name that exists only where it was written.
* A local inside a function is made fresh on every call and remembers nothing
from the last one.
* A local can only be seen below the line that creates it, so values and your
own functions go above the callbacks that use them.
* Callbacks stay global, because Singe finds them by name.
* A function is worth writing when the thing it does has a name, even if you
call it once.
=== Next Time
Your hundred and twenty stars are still a hundred and twenty copies of three
ideas. Nothing in the program is *a star*: there is a number, and some
arithmetic that turns the number into a position. You cannot say "this one is
nearer, so it falls faster and shines brighter", because there is no "this
one" to say it about, and you cannot let a star fall off the bottom and be
replaced by a new one, because there is nothing there to remove.
Lesson six gives you the thing that holds a star, and then a way to hold a
hundred and twenty of them and add a hundred and twenty-first while the game
is running. It is the most useful lesson in this part of the book.