579 lines
20 KiB
Text
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.
|