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

579 lines
20 KiB
Text

== Lesson 20: Menus and Screens
image::learn/20-gui.png[The finished lesson, 480]
You can build a menu out of what you already know. A box, some text, a cursor
that moves when the stick moves, an `if` for every choice, and arithmetic to
keep it all lined up when you add a fourth option. It works. It is also about a
hundred lines for a page with three buttons on it, and every one of those lines
is yours to maintain.
Singe has a whole document engine inside it -- RmlUi -- and a menu written for
it is a page, like a web page, with a style sheet. It wraps its own text. It
lays itself out when you add an option. It knows what a button is, what a
slider is, and which control the player is pointing at. This lesson builds a
pause menu with it, over a game that keeps running behind.
=== Two Files
A GUI page is two things: a *document* that says what is on the page, and
*Lua* that says what the page does. They live in separate files, and this is
the whole point of the exercise -- the layout stops being code.
Start with a script called `menu.singe` and a game so small it fits in a
paragraph: a ship crossing the screen, and a score that goes up each time it
gets across. You have written this kind of thing since lesson seven.
[source,lua]
----
dofile("Singe/Framework.singe")
overlaySetResolution(discGetWidth(), discGetHeight())
local ship = spriteLoad(DIR .. "art/ship.png")
local shipX = 0
local speed = 2
local score = 0
function onOverlayUpdate()
overlayClear()
shipX = shipX + speed
if shipX > overlayGetWidth() then
shipX = -spriteGetWidth(ship)
score = score + 10
end
spriteDraw(ship, shipX, 300)
return OVERLAY_UPDATED
end
----
That runs. Now the document. Beside your script, make a file called
`pause.rml`:
[source,html]
----
<rml>
<head>
<title>Pause</title>
<link type="text/rcss" href="Singe/gui.rcss"/>
<style>
body { width: 100%; height: 100%; }
#frame { width: 380dp; margin: 80dp auto; }
.row { display: block; margin: 8dp 0; }
label { display: inline-block; width: 100dp; }
button { nav: auto; }
input.range { nav: auto; width: 200dp; }
#status { min-height: 28dp; }
</style>
</head>
<body id="pause">
<div id="frame" class="panel">
<h1>Paused</h1>
<p class="row">Score <span id="score">000000</span></p>
<div class="row"><label>Speed</label><input type="range" id="speed" min="1" max="8" step="1" value="2"/></div>
<div class="row">
<button id="resume">Resume</button>
<button id="restart">Restart</button>
<button id="quit">Quit</button>
</div>
<p id="status" class="muted">Arrow keys move, Return chooses.</p>
</div>
</body>
</rml>
----
If you have ever seen a web page's source, that is familiar. If you have not,
here is everything you need to read it.
A document is made of *elements*. An element starts with a name in angle
brackets, `<h1>`, ends with the same name and a slash, `</h1>`, and whatever is
between them is inside it. Some elements have nothing inside and close
themselves with a slash at the end, like the `<input .../>` above. Elements
nest, and the indentation shows the nesting the same way it shows it in Lua.
The words inside the opening tag are *attributes*, written `name="value"`.
Two of them matter to you more than the rest. `id` gives an element a name your
script can find it by, which is how Lua and the document talk to each other.
`class` puts the element in a group the style sheet can aim at; `panel`,
`muted`, and `list` are groups the engine's own style sheet already knows about.
The markup language is called RML and the style language is called RCSS.
They are RmlUi's, not Singe's. The manual's GUI chapter points at RmlUi's own
documentation for the full list of what you can write, and you will want it
eventually. Everything in this lesson works without it.
=== Show It
Back in the script. Above `onOverlayUpdate`, add:
[source,lua]
----
local gui = guiNew(overlayGetWidth(), overlayGetHeight())
local page = guiLoad(gui, DIR .. "pause.rml")
----
`guiNew` makes a *GUI*: a rectangle of a fixed size that documents are laid out
and drawn into. It returns a handle, the way `fontLoad` and `spriteLoad` do.
Making it the size of the overlay means one pixel of the document is one pixel
of your overlay, which keeps the arithmetic in your head simple.
`guiLoad` reads a document into that GUI and returns a second handle, for the
document itself. Nearly every other call takes both: the GUI and the document
in it. One GUI can hold up to thirty-two documents -- a title page, an options
page, and a game over page can share one -- and up to sixteen GUIs can exist at
once.
Nothing is on screen yet, because loading a document does not draw it. Add one
line to `onOverlayUpdate`, just before the `return`:
[source,lua]
----
guiDraw(gui)
----
Run it. The panel is there, over the ship, with its heading and its three
buttons and its slider, and you have written no drawing code at all.
`guiDraw` composites the GUI over the overlay *for this frame only*, like every
other drawing call, so it belongs in `onOverlayUpdate` and has to be called
again next frame. Where you put it decides what is on top: everything you drew
before it is underneath, and everything you draw after it goes over it. A
crosshair drawn after `guiDraw` is never hidden by the menu.
=== Let the Player Use It
Click a button. Nothing happens, and the button does not even light up.
A new GUI is a picture, not a control panel. That is deliberate: a HUD or a
sign should not swallow the fire button. To make a page take input:
[source,lua]
----
guiSetInput(gui, true)
----
Now the mouse works, the buttons light up under the pointer, and the arrow keys
move between the controls, because the document asked for that with
`nav: auto`. On a pad or a stick the four directions arrive
as the arrow keys, the first action button arrives as Return, and the second
arrives as Escape. A cabinet with a joystick and two buttons drives this page
without knowing it is a document.
Clicking still does nothing, though, because nothing is listening.
=== Talk to It
Three calls join the document to your script, and between them they do almost
everything.
[source,lua]
----
guiSetHandler(gui, page, "resume", "click", function()
closeMenu()
end)
----
`guiSetHandler` says: when the element with this `id` does this thing, call
this function. The `id` is the one in the document. The event name is RmlUi's:
`"click"` for buttons, `"change"` for anything the player adjusts, and a long
list of others you can look up when you need them. One function is kept per
element and per event, so setting another replaces it, and passing `nil`
instead of a function removes it.
Your function is called with five arguments -- the GUI, the document, the id,
the event, and the element's current value as a string. Take as many as you
want and ignore the rest, the way you already ignore arguments in Lua.
[source,lua]
----
guiSetHandler(gui, page, "speed", "change", function(g, d, id, event, value)
speed = math.floor(tonumber(value))
end)
----
The slider hands its value over as a string, and RmlUi formats numbers its own
way, so `4` arrives as `"4.000000"`. `tonumber` turns the string into a number
and `math.floor` throws away the fraction. Skipping that step and using the
string as a number is a mistake you will make once.
[source,lua]
----
guiSetValue(gui, page, "score", string.format("%06d", score))
----
`guiSetValue` writes into an element. For a form control it sets the control's
value; for anything else -- a paragraph, a `div`, the `<span id="score">` in
your document -- it replaces what is inside the element, and the page lays
itself out again on the next frame. `guiGetValue` is the same call backwards,
and it answers `nil` for an id nothing has, so you can ask about an element
that might not be there without dying.
That is the whole everyday API: `guiSetHandler`, `guiSetValue`, `guiGetValue`.
There is far more available through RmlUi's own Lua objects, reachable from the
global `rmlui`, and the manual's GUI chapter shows how. You will get a long way
before you need it.
=== Hide It Again
A pause menu is not up all the time. `guiHide` takes a document off screen and
`guiShow` puts it back, keeping everything the player typed or chose.
The pause itself is `singeSetPauseFlag(true)`, which stops the disc, the
videos, and the sounds but *keeps calling your callbacks*, so you can still
draw. The engine has its own pause key, which freezes the script completely,
and the two would fight; `singeSetPauseKeyEnabled(false)` takes that key away
from the engine so `SWITCH_PAUSE` arrives at `onInputPressed` like any other
switch. On a keyboard that key is *P*.
[source,lua]
----
local function closeMenu()
paused = false
singeSetPauseFlag(false)
guiHide(gui, page)
guiSetInput(gui, false)
end
local function openMenu()
paused = true
singeSetPauseFlag(true)
guiSetValue(gui, page, "score", string.format("%06d", score))
guiShow(gui, page)
guiSetInput(gui, true)
end
----
Turning input off with the page is not decoration. A hidden page that still
takes input eats the player's arrow keys for the rest of the game.
=== What the Page Does Not Use, You Still Get
This is the rule that makes a GUI safe to leave up during play, and it is worth
saying plainly: *an event an element used never reaches your callbacks.* A
Return that pressed the focused button does not arrive as `onInputPressed`. An
arrow that moved the focus does not arrive as `SWITCH_LEFT`. A click on a
button is not a mouse switch.
Everything the page did not use falls straight through as usual. That is why
`P` closes this menu: no element in the document uses `P`, so the key arrives
at `onInputPressed` exactly as it does when the menu is down. It is also why a
HUD that takes input still lets the fire button through.
One key does not follow the rule. RmlUi uses Tab to move focus and never gives
it back, so Singe only hands Tab to a GUI while a text field is actually being
typed in. A game that binds Tab to something keeps it.
=== Style It
Look back at the document's `<head>`. One line does most of the work:
[source,html]
----
<link type="text/rcss" href="Singe/gui.rcss"/>
----
That is the theme the engine ships, and it is why your panel has a border, a
gradient, and a gold heading without you choosing any of it. Without a style
sheet RmlUi draws almost nothing, because every element starts out inline and
sized to its contents, so linking this first and overriding what you dislike is
the fastest road to a page that looks like something.
The theme gives you `.panel` (a bordered, rounded box), `.list` (a scrolling
box whose child `div` rows highlight and take focus), `.muted` (dimmer text for
hints), headings, and every form control with its hover, focus, and pressed
states. The manual's GUI chapter lists them.
Everything after the link is yours. The `<style>` block in `pause.rml`
overrides the theme for this page, later rules winning over earlier ones, and
a rule aimed at an `id` beats one aimed at a `class`. For a real game put your
own rules in a `.rcss` file beside the document and link it second, so your
look is one file for every page.
The unit `dp` is a pixel of the GUI's own size -- your GUI is the size of the
overlay, so `380dp` is 380 overlay pixels. `margin: 80dp auto` is the trick
that centres a block of a known width: `auto` splits whatever is left over
equally between the two sides. There is no measuring and no arithmetic, which
after lesson nineteen you may find slightly annoying.
To use your own font in a document, load it with `guiLoadFont` before the
documents that use it, and name it in RCSS by the family name inside the font
file, not by its file name.
=== When Not to Use Any of This
A GUI is a page. It is the right answer for a menu, an options screen, a high
score table, a game over screen, or a HUD with a real layout.
It is the wrong answer for a score in the corner. That is two lines of
`fontPrint` from lesson nineteen, and wrapping it in a document buys you
nothing and costs you a texture. The same goes for a crosshair, a health bar
you drew with `overlayBox`, and a line of debug text.
Three facts decide it for you.
A GUI needs a GPU. On a machine without one -- an old box, a small Pi, a
machine with no Vulkan, Direct3D 12, or Metal driver at all -- there are no
GUIs, and `guiNew` does not politely answer `false`, it ends your game. The 3D
scene of lesson twenty-two goes the same way, so this is not the last time you
will ask. Ask `singeHasGpu()` first if
your game has to run in both places, and have a plain overlay version to fall
back on. The engine's own menu does exactly that.
A GUI costs a texture and a layout pass *every frame, whether or not you draw
it*. A document you are finished with should be deleted with `guiDelete`, not
merely hidden. Hiding is for a page you will show again in a minute.
And a GUI is torn down by a reload. `F5`, `-R`, and anything that restarts the
script take every GUI with them, and the script makes them again when it runs.
That is fine, as long as you build them in your script's body and not in some
corner that only runs once.
=== The Whole Script
[source,lua]
----
dofile("Singe/Framework.singe")
overlaySetResolution(discGetWidth(), discGetHeight())
singeSetPauseKeyEnabled(false)
local SHIP_Y = 300
local ship = spriteLoad(DIR .. "art/ship.png")
local hudFont = fontLoad("Singe/FreeSansBold.ttf", 20)
local shipX = 0
local speed = 2
local score = 0
local paused = false
local gui = nil
local page = nil
local function say(text)
if gui ~= nil then
guiSetValue(gui, page, "status", text)
end
end
local function closeMenu()
paused = false
singeSetPauseFlag(false)
if gui ~= nil then
guiHide(gui, page)
guiSetInput(gui, false)
end
end
local function openMenu()
paused = true
singeSetPauseFlag(true)
if gui ~= nil then
guiSetValue(gui, page, "score", string.format("%06d", score))
guiShow(gui, page)
guiSetInput(gui, true)
end
end
if singeHasGpu() then
gui = guiNew(overlayGetWidth(), overlayGetHeight())
page = guiLoad(gui, DIR .. "pause.rml")
guiHide(gui, page)
guiSetInput(gui, false)
guiSetHandler(gui, page, "resume", "click", function()
closeMenu()
end)
guiSetHandler(gui, page, "restart", "click", function()
score = 0
shipX = 0
closeMenu()
end)
guiSetHandler(gui, page, "quit", "click", function()
singeQuit()
end)
guiSetHandler(gui, page, "speed", "change", function(g, d, id, event, value)
speed = math.floor(tonumber(value))
say("Speed " .. speed)
end)
end
function onInputPressed(what)
if what == SWITCH_PAUSE then
if paused then
closeMenu()
else
openMenu()
end
end
end
function onOverlayUpdate()
overlayClear()
if not paused then
shipX = shipX + speed
if shipX > overlayGetWidth() then
shipX = -spriteGetWidth(ship)
score = score + 10
end
end
spriteDraw(ship, shipX, SHIP_Y)
fontSelect(hudFont)
colorForeground(255, 255, 255)
fontPrint(16, 16, string.format("SCORE %06d", score))
fontPrint(16, 40, "P pauses")
if paused then
if gui ~= nil then
guiDraw(gui)
else
colorForeground(255, 211, 90)
fontPrint(16, 70, "PAUSED -- no GPU, so no menu")
end
end
return OVERLAY_UPDATED
end
function onShutdown()
if gui ~= nil then
guiDelete(gui)
end
spriteUnload(ship)
fontUnload(hudFont)
end
----
=== What Just Happened
[source,lua]
----
if singeHasGpu() then
gui = guiNew(overlayGetWidth(), overlayGetHeight())
page = guiLoad(gui, DIR .. "pause.rml")
----
The whole GUI is built inside that `if`, and `gui` stays `nil` on a machine
without one. Every call that touches the GUI is guarded with `if gui ~= nil`,
and the pause still works without a menu. It is four extra lines and it is the
difference between "does not have a menu on that machine" and "does not start
on that machine".
[source,lua]
----
guiHide(gui, page)
guiSetInput(gui, false)
----
`guiLoad` shows the document it loaded, so a page that should start hidden has
to be hidden immediately. Input is off already for a new GUI, but turning it
off here means `openMenu` and `closeMenu` are exact mirrors of each other, and
mirrored pairs are much harder to get wrong.
[source,lua]
----
guiSetHandler(gui, page, "restart", "click", function()
score = 0
shipX = 0
closeMenu()
end)
----
The handler is a function with no name, written where it is used. Lua lets you
write `function(...) ... end` anywhere a value is wanted, and since
`guiSetHandler` wants a function, you can hand it one on the spot instead of
naming it first and passing the name. It reaches `score`, `shipX`, and
`closeMenu` because they are
declared above it in the same file, which is the whole reason the handlers are
set up below the things they touch.
[source,lua]
----
if not paused then
shipX = shipX + speed
----
The pause flag stops the disc and the sounds, not your arithmetic.
`onOverlayUpdate` keeps being called -- that is the point, since otherwise the
menu could not be drawn -- so anything of yours that should stop while paused,
you stop yourself.
=== Try It
. *Add a fourth button.* Copy the `<button id="quit">` line, give it a new id
and new text, and add a handler for it. Notice that nothing had to be
re-measured or moved along.
. *Restyle it.* In the `<style>` block, change `#frame` to
`width: 260dp; margin: 20dp;` and see the panel move and the text rewrap by
itself.
. *Take out the `guiSetInput(gui, true)` in `openMenu`.* The page still
appears. Work out from the screen alone what you have lost.
. *Leave the page shown all the time.* Delete the `guiHide` calls and draw the
GUI every frame instead of only while paused. Then try to fly the ship.
This is why a HUD is `guiSetInput(gui, false)`.
. *Put the score on the page instead of the HUD.* Call `guiSetValue` for
`score` every frame rather than only in `openMenu`, and delete the
`fontPrint` line. Then decide which of the two you actually prefer, and why.
=== Break It on Purpose
Change one id in `pause.rml`, from `id="resume"` to `id="continue"`, and leave
the script alone. Singe starts and dies:
----
53:guiSetHandler: No element "resume" in document 0 of GUI 0.
----
Read it as you read any engine error. The line, the call that complained, then
the complaint -- and this complaint is precise. It is not saying the document
is broken; the document loaded perfectly. It is saying you asked for an element
by a name nothing in it has.
`guiSetHandler` and `guiSetValue` both die on a missing id, because a handler
you meant to attach and did not is a bug you want to hear about at once.
`guiGetValue` is the exception: it answers `nil` instead, so you can ask
whether something is there.
The other error worth meeting is the one you get when the document itself does
not load. Rename `pause.rml` and run. This time Singe names the file and quotes
RmlUi's own reason, and RmlUi's grumbles about your markup and your styles go
to the console whether or not the load succeeds. Read that console the first
time a page comes out blank; it will usually have said why.
=== What You Learned
* A GUI is a fixed size rectangle that documents are laid out and drawn into;
`guiNew` makes one and `guiDelete` frees it.
* `guiLoad` puts an RML document in a GUI and shows it; `guiHide`, `guiShow`,
and `guiClose` manage it afterwards.
* `guiDraw` composites the GUI over the overlay for one frame, so it goes in
`onOverlayUpdate` like every other drawing call.
* A new GUI only displays; `guiSetInput` is what makes it usable.
* `guiSetHandler` runs your function when an element is clicked or changed,
`guiSetValue` writes into an element, and `guiGetValue` reads one.
* An event an element used never reaches your own callbacks; everything else
falls through as normal.
* `Singe/gui.rcss` is the shipped theme; link it first and override it in a
`<style>` block or a sheet of your own.
* A GUI needs a GPU, so ask `singeHasGpu()` if your game must run without one.
* Every GUI renders every frame whether you draw it or not, so delete the ones
you have finished with.
* A score in the corner is two lines of `fontPrint`, not a document.
=== Next Time
There is one more kind of text on screen, and it has its own system for a
reason that has nothing to do with how it looks. Lesson twenty-one is about
subtitles, and about the players who cannot play your game without them.