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

491 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 the one performance trap that catches
everybody. 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.
=== The Trap: fontPrint Is Not Free
Here is the thing the manual says plainly and everyone reads past.
`fontPrint` does not draw a picture it made earlier. Every single call
rasterizes the whole string from the font outlines, builds an image of it,
copies that image onto the overlay, and throws it away. Then, one sixtieth of a
second later, it does all of that again. For a title that has not changed since
the game started, you are re-drawing the same letters from scratch sixty times
a second, forever.
On a desktop with three lines of text you will never notice. With a screen full
of text, or on a Raspberry Pi, or in a cabinet, you will notice a great deal.
The fix is the call you just met. `fontToSprite` does that work *once*.
`spriteDraw` afterwards is a plain copy, which is what the hardware is for. So
the rule is short:
* Text that never changes -- titles, labels, instructions, menu entries --
`fontToSprite` once at startup, `spriteDraw` every frame.
* Text that changes every frame and nobody is staring at -- a debug readout, a
frame number -- `fontPrint` is fine.
* Text that changes sometimes -- a score, a name, a timer in whole seconds --
render a new sprite when it changes, and unload the old one.
That last case is worth writing out, because "unload the old one" is easy to
forget and forgetting it 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
----
A score changes perhaps twice a second. This renders twice a second instead of
sixty times, and the code to do it is nine lines.
`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 sits centred and never re-renders,
the score climbs and re-renders only when it changes, the hint blinks, and the
clock at the bottom is the one thing honestly drawn with `fontPrint` sixty
times 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. Leave it running for a minute and watch the
memory your game is using.
. *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` re-renders the string on every single call.
* `fontToSprite` renders once into a sprite, which `spriteDraw` then copies
cheaply and `spriteGetWidth` measures.
* 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.