singe/docs/lessons/19-text.adoc
2026-09-29 21:29:24 -05:00

502 lines
18 KiB
Text

== Lesson 19: Text That Looks Good
image::learn/19-text.png[The finished lesson, 480]
Every word you have put on screen since lesson one came out of `overlayPrint`,
and `overlayPrint` has exactly one font: a fixed-width block of pixels built
into the engine, the same size forever, in whatever colour it happens to be.
It is perfect for a frame counter. It is wrong for a title, a score, a menu, or
anything a player is meant to look at rather than read past.
This lesson loads a real font off disk, prints with it, colours it, measures it
so you can centre things, and shows you what printing text actually costs. At
the end you will have a title screen that looks deliberate instead of
accidental.
=== Set the Overlay to a Size You Can Think In
Start a new file called `text.singe` with two lines:
[source,lua]
----
dofile("Singe/Framework.singe")
overlaySetResolution(discGetWidth(), discGetHeight())
----
The first line you have written many times; it loads the framework, which
defines `DIR`, the `SWITCH_*` names, and the `FONT_QUALITY_*` names you are
about to use.
The second matters more than it looks. The overlay -- the surface everything
you draw lands on -- starts at *half* the size of the world. A game with no
video has a 720 by 480 canvas, so the overlay starts at 360 by 240, and a
36 point font on a 360 pixel wide overlay is enormous. `overlaySetResolution`
replaces the overlay with one of the size you ask for, and from then on every
coordinate you give, and everything `overlayGetWidth` and `overlayGetHeight`
tell you, is in that space.
`discGetWidth()` and `discGetHeight()` answer the size of the video when there
is one and the size of the canvas when there is not, so that one line gives you
an overlay that matches the world whichever kind of game this is. Call it once,
at the start, before you draw anything.
=== Load a Font
Under that, add:
[source,lua]
----
fontQuality(FONT_QUALITY_BLENDED)
local titleFont = fontLoad("Singe/FreeSansBold.ttf", 48)
----
`fontLoad` opens a TrueType font file and hands you back a number -- a *handle*
-- that you use whenever you want that font. The second argument is the point
size, and it is fixed for the life of the handle. There is no way to change the
size of a loaded font afterwards. If you want the same face at three sizes, you
call `fontLoad` three times and keep three handles.
`Singe/FreeSansBold.ttf` is the font the engine unpacked into the `Singe`
folder in your work folder the first time you ran anything. Every copy of Singe
has it, so you can rely on it. Any other `.ttf` file works the same way; put it
beside your script and load it as `DIR .. "myfont.ttf"`.
Now print with it:
[source,lua]
----
function onOverlayUpdate()
overlayClear()
fontPrint(24, 60, "ASTEROID PATROL")
return OVERLAY_UPDATED
end
----
Run it. Big white letters near the top left.
Notice what you did *not* do: you never told `fontPrint` which font to use.
`fontLoad` makes the font it just loaded the selected one, and `fontPrint`
always draws with whatever is selected. With more than one font loaded you
choose between them with `fontSelect`, which you will do in a moment.
Notice also that `24` and `60` are pixels, not character cells. This is the
difference between `fontPrint` and `overlayPrint` that trips people up:
`overlayPrint(2, 2, ...)` means two letters in and two lines down, while
`fontPrint(2, 2, ...)` means two pixels in and two pixels down, which is
jammed into the corner. The `y` you give is the *top* of the text, not the
baseline it sits on.
=== Colour
`fontPrint` takes three arguments and none of them is a colour. The colour
comes from somewhere else:
[source,lua]
----
colorForeground(255, 211, 90)
fontPrint(24, 60, "ASTEROID PATROL")
----
`colorForeground` sets red, green, and blue from `0` to `255`, and an optional
fourth number for opacity. It is not an argument to anything; it is a setting
that the engine remembers. Everything drawn after it comes out in that colour
until you change it again, exactly the way `overlayBox` and `overlayLine`
already work. So the pattern is always the same: set the colour, then draw.
This is the first thing people get wrong with fonts, and the second thing is
coming up in a few pages.
=== Quality
`fontQuality` decides how the letters are rasterized -- how the curves of a
letter are turned into pixels -- and there are three choices. Try each one by
putting it above the `fontLoad` line and looking at the result closely.
`FONT_QUALITY_SOLID` is the fastest. Every pixel is either the letter or it is
not, so the diagonals and curves come out with visible stair steps. At 48 point
it looks cheap. At 12 point on a small overlay it can look *sharper* than the
alternatives, which is sometimes what you want.
`FONT_QUALITY_SHADED` smooths the edges, but it does it by blending them into
the current background colour, and it draws that background as a solid
rectangle behind the whole string. You get a label with a box around it whether
you wanted one or not. It is the right answer for a debug readout over a busy
picture and the wrong answer for almost everything else.
`FONT_QUALITY_BLENDED` smooths the edges and keeps them see-through, so the
letters sit on whatever is behind them. It costs the most and it is what you
want nearly all the time. Use it unless you have a reason not to.
The setting is global, not per font, and it applies from the moment you call it
onwards. Sprites you have already made keep the quality they were made with.
The manual's entry for `fontQuality` lists the three names and their values.
=== Measuring, so You Can Centre Things
Sooner or later -- usually about ten minutes in -- you want the title in the
middle of the screen. You know the screen is `overlayGetWidth()` wide. You do
not know how wide the text is, and there is no `fontMeasure` call to ask.
What there is instead is `fontToSprite`. It renders a string with the selected
font, the current quality, and the current colour, and instead of putting it on
screen it hands you back a *sprite*: the same kind of handle `spriteLoad` gives
you, which means every `sprite*` call you learned in lesson nine works on it.
Including the two that measure it.
[source,lua]
----
fontSelect(titleFont)
colorForeground(255, 211, 90)
local titleImage = fontToSprite("ASTEROID PATROL")
----
And then, in `onOverlayUpdate`:
[source,lua]
----
local x = (overlayGetWidth() - spriteGetWidth(titleImage)) / 2
spriteDraw(titleImage, x, 60)
----
That is centring, and it is the whole trick. Take the width of the space, take
away the width of the thing, and half of what is left is the gap at each side.
Right alignment is the same idea with less arithmetic. To put text against the
right hand margin:
[source,lua]
----
spriteDraw(scoreImage, overlayGetWidth() - MARGIN - spriteGetWidth(scoreImage), y)
----
`spriteGetHeight` measures the other direction, for stacking lines or centring
vertically.
There is a catch in that first code block, and it is the second thing everybody
gets wrong. *The colour is baked in when the sprite is made, not when it is
drawn.* `colorForeground` above `fontToSprite` changes the text. Putting it
above `spriteDraw` instead does nothing at all, because a sprite is drawn with
its own pixels and is never tinted. If your carefully coloured title comes out
white, this is why.
=== What Printing Costs
The first time `fontPrint` sees a string, it does real work: it rasterizes
every letter from the font's outlines and builds an image of the whole string.
Then it keeps that image. The next time you print the same string, with the
same font, quality, and colours, it skips all of that and copies the image it
already has, which is about what drawing a sprite costs. A string you stop
printing is let go a second later, so nothing piles up.
That means the obvious way to write a title screen is also the fast one.
Printing `"ASTEROID PATROL"` sixty times a second renders it once. A score that
changes twice a second renders twice a second, on its own, with no bookkeeping
from you.
The one case that costs is text that is *different* every frame -- a timer in
milliseconds, a frame counter -- because every frame is a string `fontPrint`
has never seen. On a desktop it does not matter. On a Raspberry Pi or a
handheld, a screenful of it will. The cure is to change the text less often:
a timer in whole seconds is a new string once a second, not sixty times.
So why did this lesson use `fontToSprite` at all? Because a sprite can do
things printed text cannot:
* *It can be measured*, which is the only way to centre text or line it up
against the right hand side.
* *It can be scaled and turned* with the `sprite*` calls from lesson nine.
* *It can outlive its font*, which matters in a moment.
Use `fontPrint` for text, and `fontToSprite` when you need one of those.
The score in this lesson needs one of them: it sits against the right hand
margin, so it has to be measured, so it has to be a sprite. And a sprite of a
score is only right until the score changes. When it does, render a new sprite
and unload the old one -- forgetting that leaks a sprite per change:
[source,lua]
----
local function renderScore()
if scoreImage ~= nil then
spriteUnload(scoreImage)
end
fontSelect(scoreFont)
colorForeground(255, 255, 255)
scoreImage = fontToSprite(string.format("%06d", score))
scoreShown = score
end
----
`scoreShown` remembers which score that sprite says, so the drawing code can
tell whether it is still right:
[source,lua]
----
if score ~= scoreShown then
renderScore()
end
----
`string.format("%06d", score)` is plain Lua, not Singe. It turns a number into
a string, and `%06d` means "as a whole number, at least six digits, padded with
zeros", which is why arcade scores have leading zeros. Change it to `%d` and
the zeros go away.
=== Putting Fonts Away
`fontUnload` closes a font and makes its handle invalid. Sprites you made with
`fontToSprite` are not affected -- once rendered, a sprite has nothing more to
do with the font -- so you can unload a font and go on drawing text made from
it.
One sharp edge: if you unload the font that was selected, *no* font is selected
afterwards, and the next `fontPrint` ends your game with an error. Select
another one first if you are going to keep printing.
The tidy place for all this is `onShutdown`, a callback the engine calls once
when the game is closing:
[source,lua]
----
function onShutdown()
spriteUnload(titleImage)
fontUnload(titleFont)
end
----
Singe cleans up after you when the game ends, so nothing catches fire if you
skip this. Write it anyway. A game that releases what it loaded is a game you
can load twice.
=== The Whole Script
[source,lua]
----
dofile("Singe/Framework.singe")
overlaySetResolution(discGetWidth(), discGetHeight())
local MARGIN = 24
local TITLE_Y = 60
local SCORE_Y = 170
local LABEL_Y = 186
local HINT_Y = 300
local CLOCK_Y = 430
local score = 0
local titleFont = nil
local scoreFont = nil
local smallFont = nil
local titleImage = nil
local hintImage = nil
local scoreImage = nil
local scoreShown = nil
local function centred(image)
return (overlayGetWidth() - spriteGetWidth(image)) / 2
end
local function renderScore()
if scoreImage ~= nil then
spriteUnload(scoreImage)
end
fontSelect(scoreFont)
colorForeground(255, 255, 255)
scoreImage = fontToSprite(string.format("%06d", score))
scoreShown = score
end
fontQuality(FONT_QUALITY_BLENDED)
titleFont = fontLoad("Singe/FreeSansBold.ttf", 48)
scoreFont = fontLoad("Singe/FreeSansBold.ttf", 36)
smallFont = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(titleFont)
colorForeground(255, 211, 90)
titleImage = fontToSprite("ASTEROID PATROL")
fontSelect(smallFont)
colorForeground(140, 200, 255)
hintImage = fontToSprite("Press the fire button to score")
renderScore()
function onInputPressed(what)
if what == SWITCH_BUTTON1 then
score = score + 125
end
end
function onOverlayUpdate()
overlayClear()
spriteDraw(titleImage, centred(titleImage), TITLE_Y)
if score ~= scoreShown then
renderScore()
end
spriteDraw(scoreImage, overlayGetWidth() - MARGIN - spriteGetWidth(scoreImage), SCORE_Y)
fontSelect(smallFont)
colorForeground(160, 160, 160)
fontPrint(MARGIN, LABEL_Y, "SCORE")
fontPrint(MARGIN, CLOCK_Y, "Running for " .. (singeGetTicks() // 1000) .. " seconds")
if (singeGetTicks() // 500) % 2 == 0 then
spriteDraw(hintImage, centred(hintImage), HINT_Y)
end
return OVERLAY_UPDATED
end
function onShutdown()
spriteUnload(titleImage)
spriteUnload(hintImage)
if scoreImage ~= nil then
spriteUnload(scoreImage)
end
fontUnload(titleFont)
fontUnload(scoreFont)
fontUnload(smallFont)
end
----
Run it and hold the fire button. The title and the hint sit centred, the score
climbs against the right margin and is re-rendered only when it changes, the
hint blinks, and the label and the clock are printed with `fontPrint` every
frame -- the clock in whole seconds, so it is a new string only once a second.
=== What Just Happened
[source,lua]
----
titleFont = fontLoad("Singe/FreeSansBold.ttf", 48)
scoreFont = fontLoad("Singe/FreeSansBold.ttf", 36)
smallFont = fontLoad("Singe/FreeSansBold.ttf", 18)
----
One file, three sizes, three handles. Each one costs memory for its own set of
rendered glyphs, so load the sizes you use and no more. Three is normal. A
dozen is a smell.
After these three lines the selected font is `smallFont`, because loading
selects. That is why the code calls `fontSelect(titleFont)` before rendering
the title: the last thing loaded is not the thing you want first.
[source,lua]
----
local function centred(image)
return (overlayGetWidth() - spriteGetWidth(image)) / 2
end
----
Written once as a function because it is used twice, and because the next time
you want something centred you will want it again. Give the arithmetic a name
and you never have to read it again.
[source,lua]
----
if (singeGetTicks() // 500) % 2 == 0 then
spriteDraw(hintImage, centred(hintImage), HINT_Y)
end
----
`singeGetTicks()` is the number of milliseconds since the engine started. `//`
is division that throws away the remainder, so `singeGetTicks() // 500` counts
half-seconds, and `% 2` is the remainder after dividing by two, which is `0`,
`1`, `0`, `1`... So the hint is drawn for half a second and skipped for half a
second. Drawing nothing is how you make something blink.
[source,lua]
----
fontSelect(smallFont)
colorForeground(160, 160, 160)
fontPrint(MARGIN, LABEL_Y, "SCORE")
----
Both settings are needed here, and it is worth being clear about why. Drawing
a sprite does not change the selected font or the colour, so the two
`spriteDraw` calls above leave both exactly as they found them. But the setup
code selected three different fonts in turn, and `renderScore` selects another
one whenever the score moves, so the selection when this line runs is whichever
one happened to be chosen last. Do not try to keep track of it. Select the font
and set the colour immediately before you print, every time. It is two lines
and it removes a whole category of puzzled evening.
=== Try It
. *Change the quality.* Put `FONT_QUALITY_SOLID` in the `fontQuality` line and
look at the curve of the `S` in `ASTEROID`. Then try `FONT_QUALITY_SHADED`
and watch what happens behind every string.
. *Make the hint yellow.* Move the `colorForeground(140, 200, 255)` line from
above `fontToSprite` to just above the `spriteDraw` that draws `hintImage`,
and change the numbers. Work out why nothing happens.
. *Right align the clock.* The clock is drawn with `fontPrint`, which cannot be
measured. Get it against the right margin anyway. There is only one way, and
finding it is the point.
. *Take out the `scoreShown` check*, so `renderScore()` runs every frame
instead. It will look identical, but it now renders the score sixty times a
second instead of when it changes -- exactly the work `fontPrint` saves you
when you let it. Then take out the `spriteUnload` in `renderScore` as well,
leave it running for a minute, and watch the memory your game is using.
. *Print a clock in milliseconds.* Change the clock line to print
`singeGetTicks()` without the `// 1000`. It still works; it is now a string
`fontPrint` has never seen, every frame. On a slow machine, that is the kind
of text that costs.
. *Use your own font.* Find a `.ttf` on your machine, copy it beside your
script, and load it with `DIR .. "thatfont.ttf"`. Some fonts look terrible at
18 point and fine at 48. That is the font's fault, not yours.
=== Break It on Purpose
Add a line reading `fontPrint(24, 60, "SCORE")` directly under
`overlaySetResolution(discGetWidth(), discGetHeight())`, so that it runs before
any `fontLoad` has. Singe starts and dies at once:
----
5:fontPrint: No font selected.
----
Read it the same way as any other error. `5` is the line. `fontPrint` is which
engine call complained -- this shape of message comes from inside the engine
rather than from Lua, so it names the function instead of the file. The rest is
the complaint, and here it is exact: you asked it to print and there was no
font to print with.
You will meet this one for real in a less obvious way. Unload the selected font
while the game is running and the next `fontPrint` says precisely the same
thing, several hundred lines away from the `fontUnload` that caused it.
=== What You Learned
* `fontLoad` opens a TrueType file at one fixed point size and returns a
handle. One size, one handle.
* Loading a font selects it; `fontSelect` chooses between loaded fonts.
* `fontPrint` counts in pixels and `overlayPrint` counts in character cells.
* Colour is not an argument. `colorForeground` is a setting; set it, then draw.
* `fontQuality` picks how letters are rasterized, and `FONT_QUALITY_BLENDED`
is the usual answer.
* `fontPrint` keeps what it renders, so printing the same text every frame is
cheap; text that is different every frame is what costs.
* `fontToSprite` renders into a sprite, which `spriteGetWidth` measures and the
`sprite*` calls can scale and turn. Use it for that, not for speed.
* Measuring is how you centre or right align text, because nothing else can
tell you how wide a string will be.
* A sprite's colour is fixed when it is made, not when it is drawn.
* `fontUnload` frees a font; the sprites it made survive it.
=== Next Time
Text that looks good is a long way from a menu that works. A page with a
title, a list you move a cursor through, a slider, and a button is a hundred
lines of drawing and cursor arithmetic if you build it out of the calls in this
lesson. Lesson twenty does not build it out of those calls at all.