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

404 lines
14 KiB
Text

== Lesson 2: Numbers That Change
image::learn/02-numbers.png[The finished lesson, 480]
In lesson one you put a word on the screen and it sat there. By the end of this
lesson the same word will be sliding around the window, turning round when it
reaches an edge, and it will be doing it because of four numbers that the
program changes sixty times a second.
Along the way you will meet the single idea that trips up more beginners than
any other: where you put something decides how long it lives.
=== Start a New Folder
Make a folder called `numbers` next to your `hello` folder, and inside it a
file called `numbers.singe`. Type the lesson one program into it, or copy
`hello.singe` over and rename it -- either is fine, and typing it again is not
wasted.
[source,lua]
----
function onOverlayUpdate()
overlayClear()
overlayPrint(2, 2, "Hello!")
return OVERLAY_UPDATED
end
----
Run it with `Singe -R numbers` and leave it running. The `-R` from the
end of lesson one means you will not have to start it again for the rest of
this lesson; save the file and the window catches up on its own.
=== Count the Frames
Lesson one said that Singe calls `onOverlayUpdate` about sixty times a second.
That is easy to say and hard to believe, so count them.
[source,lua]
----
function onOverlayUpdate()
frames = 0
frames = frames + 1
overlayClear()
overlayPrint(2, 2, "Hello!")
overlayPrint(0, 0, "frame " .. frames)
return OVERLAY_UPDATED
end
----
Save it. In the top left corner you get `frame 1`, and it stays at `frame 1`
forever.
That is disappointing, and it is also the most useful thing in this lesson, so
do not fix it yet. Look at what you wrote and think about the order of the
lines, which lesson one said was the most important thing about a program.
Singe calls `onOverlayUpdate`. The first line sets `frames` to zero. The
second adds one to it, making one. You print it, you return, and the function
is over. Then Singe calls it again, and the first thing that happens is that
`frames` is set back to zero.
You are not counting frames. You are counting to one, sixty times a second.
=== Move the Line Out
Take the `frames = 0` line out of the function and put it above, on its own,
before the word `function`.
[source,lua]
----
frames = 0
function onOverlayUpdate()
frames = frames + 1
overlayClear()
overlayPrint(2, 2, "Hello!")
overlayPrint(0, 0, "frame " .. frames)
return OVERLAY_UPDATED
end
----
Save it. The number climbs, fast, and does not stop.
Nothing else changed. The only difference is *where* the line is, and that is
the whole of the idea. Lines inside a function happen every single time the
function is called. Lines outside every function happen once, when Singe reads
your file, before the game starts.
`frames = 0` needed to happen once. It was happening sixty times a second, and
it was wiping out the work of the line under it.
Every moving thing you ever write has this shape. Set the number up outside,
change it inside.
=== What Just Happened
Three new pieces of code are in there. Here they are on their own.
[source,lua]
----
frames = 0
----
This makes up a name, `frames`, and puts the number zero behind it. A name
with a value behind it is a *variable*, and it is called that because the value
is allowed to vary -- to change while the program runs.
The `=` is not the equals sign from school. It does not say that `frames` is
equal to zero; it says *put zero into `frames`*, and it always works in that
direction, right into left. Programmers call it *assignment*, and it is worth
saying the line out loud that way as you write it: "frames gets zero".
You choose the name. It can be almost anything that starts with a letter and
has no spaces in it, and it should say what the thing is. `frames` is a good
name. `f` is a bad one, and you will not thank yourself for it in a week.
[source,lua]
----
frames = frames + 1
----
This is the line that makes people stop and stare, because as a statement about
arithmetic it is nonsense: nothing is equal to itself plus one.
It is not a statement about arithmetic. Remember that `=` puts the right-hand
side into the left-hand name, and read it in that order, right first. Work out
`frames + 1` using whatever is in `frames` right now. Then put the answer back
into `frames`. If `frames` held six, the right-hand side works out to seven,
and seven goes into `frames`.
Adding one to a variable like this is something you will write thousands of
times.
The `+` is what you expect, and so are the other three: `-` subtracts, `*`
multiplies, and `/` divides. There is no `x` key for multiply, which is why the
star does that job.
[source,lua]
----
overlayPrint(0, 0, "frame " .. frames)
----
`overlayPrint` wants text, and `frames` is a number, not text. The two dots
join the two together and hand the result over as one string: the four
characters `frame`, a space, and then however many digits the number needs.
Those two dots are the joining operator, and it is worth knowing its real name
because the error messages use it: joining strings is called *concatenation*.
Note the space inside the quotes, before the closing one. Without it you get
`frame6`. Lua joins exactly what you give it and adds nothing of its own.
Lua will turn a number into text for you when you join it to a string this
way, which is why you did not have to do anything special. It will not do that
for everything. If you ever try to join something that is neither text nor a
number, Lua stops with an error, and the way round it is `tostring`, which
turns anything at all into text:
[source,lua]
----
overlayPrint(0, 0, "frame " .. tostring(frames))
----
That line does exactly the same as the one before it. You do not need
`tostring` for numbers. You will need it in lesson three.
=== Make It Move
You now have everything you need to move the word. The word is drawn wherever
you say, you have a way to keep a number between frames, and you have a way to
change it.
Give the column its own variable outside the function, and add to it inside.
[source,lua]
----
frames = 0
wordX = 0
function onOverlayUpdate()
frames = frames + 1
wordX = wordX + 1
overlayClear()
overlayPrint(wordX, 2, "Hello!")
overlayPrint(0, 0, "frame " .. frames)
return OVERLAY_UPDATED
end
----
Save it. The word shoots off the right-hand side and never comes back.
It moved one column every frame, sixty times a second, so it crossed the whole
window in about a second. Slow it down by adding less than a whole column:
[source,lua]
----
wordX = wordX + 0.25
----
Now it takes four frames to move one column, and the word creeps. `wordX` is
mostly a number like `12.75`, which is not a column at all, and Singe deals
with that by throwing away everything after the dot when it draws: `12.75`
draws in column twelve. Keeping the fraction in the variable is exactly what
lets you move slower than one column a frame. The number is finer than the
screen is.
=== Where Is the Edge?
The word still leaves. To turn it round you have to know where the right-hand
edge is, and you could find that out by counting: a game with no video gets a
world 720 by 480 by default, the overlay you draw on is half that, 360 by 240,
and the built-in console font's letters are six pixels wide, so there are sixty
columns.
Do not do that. Ask the engine.
[source,lua]
----
lastColumn = overlayGetWidth() // CELL_WIDTH - string.len(word)
----
`overlayGetWidth` and `overlayGetHeight` are two more functions Singe provides,
like `overlayPrint`. They take nothing and hand back a number: how wide and how
tall the drawing surface is. You saw `return` hand a value back in lesson one,
and this is the other end of it -- a function you call can give you something,
and here you are doing arithmetic with what it gave you.
The numbers they give you are in pixels, and `overlayPrint` counts in character
cells, so you have to divide. `overlayGetFontWidth` tells you how many pixels
wide one cell is, and `overlayGetFontHeight` how tall, so you never have to
know the answer yourself. The two slashes mean *divide and throw the fraction
away*, because a third of a character cell is not a place you can print. The
ordinary single slash would give you a fraction, and a fraction of a cell is
not a place either.
`string.len` counts the characters in a string, and it is there so that the
word turns round when its *last* letter reaches the edge rather than its first.
Try it without and you will see six letters slide off into nothing.
Those two capitalized names are a habit rather than a rule. Lua does not care,
but a name in capitals is how programmers say "this is a number I set once at
the top and never touch again", and it beats having a bare `6` halfway down a
file that nobody can explain a month later.
=== Turn It Round
Moving the other way is no work at all: instead of adding `0.25` every frame,
add `-0.25`. So keep the amount in a variable of its own and flip its sign when
the word arrives at an edge.
[source,lua]
----
if wordX > lastColumn then
wordX = lastColumn
speedX = -speedX
end
----
That is a decision, and decisions are the whole of lesson three, so take it on
trust for one lesson. Read it out: *if* `wordX` has got past `lastColumn`,
*then* do the two indented lines; otherwise skip them. The `end` closes it, the
same way `end` closes a function.
The first line pins the word exactly on the edge, so that a fast word cannot
sail past it. The second is the flip. `-speedX` is minus whatever `speedX`
holds, so if it held `0.25` it now holds `-0.25`, and adding `-0.25` every
frame walks the word back the way it came. When it reaches the left edge, the
matching `if` flips it again, and it flips back to `0.25`.
Two more of those for the top and bottom edges and the word bounces around the
whole window.
=== The Whole Thing
[source,lua]
----
CELL_WIDTH = overlayGetFontWidth()
CELL_HEIGHT = overlayGetFontHeight()
TOP_ROW = 2
word = "Hello!"
wordX = 0
wordY = TOP_ROW
speedX = 0.25
speedY = 0.125
frames = 0
lastColumn = overlayGetWidth() // CELL_WIDTH - string.len(word)
lastRow = overlayGetHeight() // CELL_HEIGHT - 1
function onOverlayUpdate()
frames = frames + 1
wordX = wordX + speedX
wordY = wordY + speedY
if wordX < 0 then
wordX = 0
speedX = -speedX
end
if wordX > lastColumn then
wordX = lastColumn
speedX = -speedX
end
if wordY < TOP_ROW then
wordY = TOP_ROW
speedY = -speedY
end
if wordY > lastRow then
wordY = lastRow
speedY = -speedY
end
overlayClear()
overlayPrint(0, 0, "frame " .. frames)
overlayPrint(wordX, wordY, word)
return OVERLAY_UPDATED
end
----
`TOP_ROW` keeps the word out of the top two lines so that it never scribbles
over the frame counter. `lastRow` has a `- 1` in it rather than a
`string.len`, because a word is many characters wide but only ever one line
tall.
Notice that the whole top half of the file runs once and the whole bottom half
runs sixty times a second, and that you can tell which is which by looking at
the indentation. That is what the spaces were for.
=== Try It
. *Change the speeds.* Try `speedX = 1` and `speedY = 1`. Then try `0.05` for
both. Then make one much bigger than the other.
. *Change the word.* Put a much longer word in the quotes -- long enough to
fill a third of the window -- and watch the right-hand bounce still happen in
the right place. That is `string.len` doing its job.
. *Take the clear out.* Delete the `overlayClear()` line. Lesson one promised
this would matter one day, and this is the day: the word smears a trail
across the window and never rubs any of it out, because drawing has always
drawn on top of what was there. Put it back.
. *Stop it going sideways.* Set `speedX = 0` and run it. Work out why the word
now never leaves column zero, and why the `if` for the left edge never fires.
. *Break the frame counter on purpose.* Move the `frames = 0` line back inside
the function, above `frames = frames + 1`. Watch the counter stick at one
while the word keeps moving perfectly. Then work out why the word is
unaffected.
=== Break It on Purpose
Change the two dots on the frame counter line to a plus sign, so that it reads:
[source,lua]
----
overlayPrint(0, 0, "frame " + frames)
----
Save it. The window closes, Singe stops, and the terminal says:
----
Error executing function 'onOverlayUpdate': numbers.singe:42: attempt to add a 'string' with a 'number'
----
followed by several lines starting with `stack traceback:`, which you can
ignore for now.
Read it the way lesson one taught you: the file, the line, then the complaint.
Line 42 is the line you changed. The complaint says Lua was asked to *add* a
string to a number, and adding is for numbers. `+` and `..` look similar on the
page and do entirely different jobs: `+` is arithmetic, `..` is glue.
This one is worth meeting because of where it appeared. Lesson one's error
stopped Singe before the window ever opened, because Lua could not read the
file at all. This one is a perfectly readable line that only goes wrong when it
runs, so the window opened, the game ran, and it died on the first frame. Both
kinds tell you the file and the line. Start there either way.
=== What You Learned
* A variable is a name with a value behind it, and the value can change.
* `=` means "put the right-hand side into the name on the left", not "is equal
to".
* `frames = frames + 1` works out the right-hand side first, then stores it.
* `+`, `-`, `*`, and `/` do the arithmetic you expect; `//` divides and throws
the fraction away.
* `..` joins text together, and turns a number into text on the way.
* A line inside `onOverlayUpdate` runs every frame. A line outside every
function runs once, before the game starts.
* Anything that has to remember something between frames has to live outside.
* `overlayGetWidth` and `overlayGetHeight` tell you the size of the drawing
surface, so you never have to guess it.
* `overlayClear` matters the moment anything moves.
=== Next Time
The word moves, but it moves on its own and nothing you do makes any
difference to it. In lesson three you take the four `if` blocks you used on
trust, learn what they really say, and point them at the arrow keys instead of
the edges of the screen. The box you steer around the window at the end of it
is the player of the game you build in lesson seven.