singe/docs/es/lessons/20-gui.adoc

628 lines
23 KiB
Text

[#_lesson_20_menus_and_screens]
== Lección 20: Menús y pantallas
image::learn/20-gui.png[La lección terminada, 480]
Puedes construir un menú con lo que ya sabes. Una caja, algo de texto, un
cursor que se mueve cuando se mueve el stick, un `if` para cada opción, y
aritmética para que todo siga alineado cuando añadas una cuarta opción.
Funciona. También son unas cien líneas para una página con tres botones, y cada
una de esas líneas te toca mantenerla a ti.
Singe lleva dentro todo un motor de documentos -- RmlUi -- y un menú escrito
para él es una página, como una página web, con una hoja de estilo. Reparte él
solo su texto en líneas. Se vuelve a maquetar solo cuando añades una opción.
Sabe qué es un botón, qué es un deslizador y a qué control está apuntando el
jugador. Esta lección construye con él un menú de pausa, sobre un juego que
sigue funcionando por detrás.
[#_two_files]
=== Dos archivos
Una página de GUI son dos cosas: un *documento* que dice qué hay en la página,
y *Lua* que dice qué hace la página. Viven en archivos separados, y esa es toda
la gracia del ejercicio -- la maquetación deja de ser código.
Empieza con un script llamado `menu.singe` y un juego tan pequeño que cabe en
un párrafo: una nave que cruza la pantalla y una puntuación que sube cada vez
que llega al otro lado. Llevas escribiendo este tipo de cosas desde la lección
siete.
[source,lua]
----
dofile("Singe/Framework.singe")
overlaySetResolution(discGetWidth(), discGetHeight())
local ship = spriteLoad(DIR .. "art/spaceship.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
----
Eso funciona. Ahora el documento. Junto a tu script, crea un archivo llamado
`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" autofocus>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>
----
Si alguna vez has visto el código fuente de una página web, esto te sonará. Si
no, aquí tienes todo lo que necesitas para leerlo.
Un documento está hecho de *elementos*. Un elemento empieza con un nombre entre
corchetes angulares, `<h1>`, termina con el mismo nombre y una barra, `</h1>`, y
lo que haya entre los dos está dentro de él. Algunos elementos no tienen nada
dentro y se cierran solos con una barra al final, como el `<input .../>` de
arriba. Los elementos se anidan, y la sangría muestra el anidamiento igual que
lo muestra en Lua.
Las palabras que hay dentro de la etiqueta de apertura son *atributos*, y se
escriben `name="value"`. Dos de ellos te importan más que el resto. `id` le da a
un elemento un nombre por el que tu script puede encontrarlo, que es como se
hablan Lua y el documento. `class` mete el elemento en un grupo al que puede
apuntar la hoja de estilo; `panel`, `muted` y `list` son grupos que la hoja de
estilo del propio motor ya conoce.
El lenguaje de marcado se llama RML y el lenguaje de estilos se llama RCSS. Son
de RmlUi, no de Singe. El capítulo sobre la GUI de la referencia te remite a la
documentación de RmlUi para la lista completa de lo que puedes escribir, y
tarde o temprano la querrás. Todo lo de esta lección funciona sin ella.
[#_show_it]
=== Muéstrala
De vuelta al script. Encima de `onOverlayUpdate`, añade:
[source,lua]
----
local gui = guiNew(overlayGetWidth(), overlayGetHeight())
local page = guiLoad(gui, DIR .. "pause.rml")
----
`guiNew` crea una *GUI*: un rectángulo de tamaño fijo en el que se maquetan y se
dibujan los documentos. Devuelve un handle, igual que `fontLoad` y
`spriteLoad`. Hacerla del tamaño del overlay significa que un píxel del
documento es un píxel de tu overlay, y así las cuentas que haces de cabeza
siguen siendo sencillas.
`guiLoad` lee un documento en esa GUI y devuelve un segundo handle, para el
propio documento. Casi todas las demás llamadas reciben los dos: la GUI y el
documento que hay en ella. Una GUI puede contener hasta treinta y dos
documentos -- una página de título, una de opciones y una de fin de partida
pueden compartir una -- y pueden existir hasta dieciséis GUI a la vez.
Todavía no hay nada en pantalla, porque cargar un documento no lo dibuja. Añade
una línea a `onOverlayUpdate`, justo antes del `return`:
[source,lua]
----
guiDraw(gui)
----
Ejecútalo. Ahí está el panel, encima de la nave, con su título, sus tres
botones y su deslizador, y no has escrito nada de código de dibujo.
`guiDraw` compone la GUI sobre el overlay *solo para este fotograma*, como
todas las demás llamadas de dibujo, así que va en `onOverlayUpdate` y hay que
volver a llamarla en el fotograma siguiente. Dónde la pongas decide qué queda
encima: todo lo que dibujaste antes queda debajo, y todo lo que dibujes después
va por encima. Un punto de mira dibujado después de `guiDraw` nunca queda tapado
por el menú.
[#_let_the_player_use_it]
=== Deja que el jugador la use
Haz clic en un botón. No pasa nada, y el botón ni siquiera se ilumina.
Una GUI nueva es un dibujo, no un panel de control. Es a propósito: un HUD o un
cartel no deberían tragarse el botón de disparo. Para que una página reciba
entrada:
[source,lua]
----
guiSetInput(gui, true)
----
Ahora el ratón funciona, los botones se iluminan bajo el puntero y las teclas de
flecha saltan de un control a otro, porque el documento lo pidió con
`nav: auto`. En un mando o en un stick, las cuatro direcciones llegan como las
teclas de flecha, el primer botón de acción llega como Intro y el segundo llega
como Esc. Una recreativa con un joystick y dos botones maneja esta página sin
saber que es un documento.
Del control sobre el que actúan las teclas se dice que tiene el *foco*, y un
botón con el foco lleva un borde dorado. El foco empieza en Resume porque ese
botón dice `autofocus`: cada vez que se muestra la página, el foco va al control
marcado así. Si lo quitas, las teclas de flecha no tienen desde dónde moverse
hasta que el jugador hace clic en algo, lo que en una recreativa sin ratón no
pasa nunca.
Pero hacer clic sigue sin hacer nada, porque no hay nada escuchando.
[#_talk_to_it]
=== Háblale
Tres llamadas unen el documento a tu script, y entre las tres lo hacen casi
todo.
[source,lua]
----
guiSetHandler(gui, page, "resume", "click", function()
closeMenu()
end)
----
`guiSetHandler` dice: cuando el elemento con este `id` haga esto, llama a esta
función. El `id` es el del documento. El nombre del evento es de RmlUi:
`"click"` para los botones, `"change"` para cualquier cosa que el jugador
ajuste, y una larga lista de otros que puedes consultar cuando los necesites. Se
guarda una función por elemento y por evento, así que poner otra sustituye a la
anterior, y pasar `nil` en lugar de una función la quita.
Tu función se llama con cinco argumentos -- la GUI, el documento, el id, el
evento y el valor actual del elemento como cadena. Recoge los que quieras e
ignora el resto, igual que ya ignoras argumentos en Lua.
[source,lua]
----
guiSetHandler(gui, page, "speed", "change", function(g, d, id, event, value)
speed = math.floor(tonumber(value))
end)
----
El deslizador entrega su valor como cadena, y RmlUi escribe los números a su
manera, así que `4` llega como `"4.000000"`. `tonumber` convierte la cadena en
un número y `math.floor` tira los decimales. Saltarte ese paso y usar la cadena
como si fuera un número es un error que cometerás una vez.
[source,lua]
----
guiSetValue(gui, page, "score", string.format("%06d", score))
----
`guiSetValue` escribe en un elemento. En un control de formulario pone el valor
del control; en cualquier otra cosa -- un párrafo, un `div`, el
`<span id="score">` de tu documento -- sustituye lo que hay dentro del elemento,
y la página se vuelve a maquetar en el fotograma siguiente. `guiGetValue` es la
misma llamada al revés, y responde `nil` para un id que no tiene ningún
elemento, así que puedes preguntar por un elemento que quizá no exista sin
morir en el intento.
Esa es toda la API del día a día: `guiSetHandler`, `guiSetValue`, `guiGetValue`.
Hay muchísimo más a tu alcance a través de los propios objetos Lua de RmlUi, a
los que se llega desde la variable global `rmlui`, y el capítulo sobre la GUI de
la referencia explica cómo. Llegarás muy lejos antes de necesitarlo.
[#_hide_it_again]
=== Vuelve a esconderla
Un menú de pausa no está a la vista todo el tiempo. `guiHide` quita un
documento de la pantalla y `guiShow` lo vuelve a poner, conservando todo lo que
el jugador haya escrito o elegido.
La pausa en sí es `singeSetPauseFlag(true)`, que detiene el disco, los vídeos y
los sonidos pero *sigue llamando a tus callbacks*, así que todavía puedes
dibujar. El motor tiene su propia tecla de pausa, que congela el script por
completo, y las dos se pelearían; `singeSetPauseKeyEnabled(false)` le quita esa
tecla al motor para que `SWITCH_PAUSE` llegue a `onInputPressed` como cualquier
otro interruptor. En un teclado, esa tecla es la *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
----
Apagar la entrada junto con la página no es decoración. Una página escondida
que sigue recibiendo entrada se come las teclas de flecha del jugador durante
el resto de la partida.
[#_what_the_page_does_not_use_you_still_get]
=== Lo que la página no usa te sigue llegando
Esta es la regla que hace que sea seguro dejar una GUI a la vista durante la
partida, y vale la pena decirla claramente: *un evento que ha usado un elemento
nunca llega a tus callbacks.* Un Intro que ha pulsado el botón que tiene el
foco no llega como `onInputPressed`. Una flecha que ha movido el foco no llega
como `SWITCH_LEFT`. Un clic en un botón no es un interruptor del ratón.
Todo lo que la página no ha usado la atraviesa y te llega como siempre. Por eso
`P` cierra este menú: ningún elemento del documento usa `P`, así que la tecla
llega a `onInputPressed` exactamente igual que cuando el menú no está. También
es por eso que un HUD que recibe entrada sigue dejando pasar el botón de
disparo.
Hay una tecla que no sigue la regla. RmlUi usa la tecla Tab para mover el foco y
nunca la devuelve, así que Singe solo le pasa Tab a una GUI mientras alguien
está escribiendo de verdad en un campo de texto. Un juego que asigna Tab a algo
la conserva.
[#_style_it]
=== Dale estilo
Vuelve a mirar el `<head>` del documento. Una sola línea hace casi todo el
trabajo:
[source,html]
----
<link type="text/rcss" href="Singe/gui.rcss"/>
----
Ese es el tema que trae el motor, y es la razón de que tu panel tenga un borde,
un degradado y un título dorado sin que tú hayas elegido nada de eso. Sin una
hoja de estilo, RmlUi casi no dibuja nada, porque todos los elementos empiezan
en línea y del tamaño de su contenido, así que enlazar esta primero y
sobrescribir lo que no te guste es el camino más rápido hacia una página que
parezca algo.
El tema te da `.panel` (una caja con borde y esquinas redondeadas), `.list`
(una caja con desplazamiento cuyas filas `div` hijas se resaltan y reciben el
foco), `.muted` (texto más tenue para las pistas), títulos, y todos los
controles de formulario con sus estados al pasar el ratón, con el foco y
pulsados. El capítulo sobre la GUI de la referencia los enumera.
Todo lo que va después del enlace es tuyo. El bloque `<style>` de `pause.rml`
sobrescribe el tema para esta página: las reglas posteriores ganan a las
anteriores, y una regla que apunta a un `id` gana a una que apunta a una
`class`. En un juego de verdad, pon tus propias reglas en un archivo `.rcss`
junto al documento y enlázalo en segundo lugar, para que tu aspecto sea un solo
archivo para todas las páginas.
La unidad `dp` es un píxel del tamaño propio de la GUI -- tu GUI es del tamaño
del overlay, así que `380dp` son 380 píxeles del overlay. `margin: 80dp auto`
es el truco que centra un bloque de anchura conocida: `auto` reparte lo que
sobra a partes iguales entre los dos lados. No hay que medir nada ni hacer
cuentas, cosa que, después de la lección diecinueve, quizá te resulte un poco
molesta.
Para usar tu propia fuente en un documento, cárgala con `guiLoadFont` antes de
los documentos que la usan, y nómbrala en RCSS por el nombre de familia que hay
dentro del archivo de la fuente, no por el nombre del archivo.
[#_when_not_to_use_any_of_this]
=== Cuándo no usar nada de esto
Una GUI es una página. Es la respuesta correcta para un menú, una pantalla de
opciones, una tabla de récords, una pantalla de fin de partida o un HUD con una
maquetación de verdad.
Es la respuesta equivocada para una puntuación en una esquina. Eso son dos
líneas de `fontPrint` de la lección diecinueve, y meterlo en un documento no te
da nada y te cuesta una textura. Lo mismo vale para un punto de mira, una barra
de vida que has dibujado con `overlayBox` y una línea de texto de depuración.
Tres hechos lo deciden por ti.
Una GUI necesita una GPU. En una máquina sin ella -- un ordenador viejo, una Pi
pequeña, una máquina sin ningún controlador de Vulkan, Direct3D 12 o Metal --
no hay GUI, y `guiNew` no responde educadamente `false`: termina tu juego. La
escena 3D de la lección veintidós va por el mismo camino, así que no será la
última vez que lo preguntes. Pregunta antes a `singeHasGpu()` si tu juego tiene
que funcionar en los dos sitios, y ten una versión con el overlay de siempre a
la que recurrir. El menú del propio motor hace exactamente eso.
Una GUI cuesta una textura y una pasada de maquetación *en cada fotograma, la
dibujes o no*. Una GUI con la que has terminado debería borrarse con
`guiDelete`, no solo esconderse. Esconder es para una página que vas a volver a
mostrar dentro de un minuto.
Y una recarga se lleva por delante las GUI. `F5`, `-R` y cualquier cosa que
reinicie el script se llevan todas las GUI consigo, y el script las vuelve a
crear cuando se ejecuta. Eso está bien, siempre que las construyas en el cuerpo
de tu script y no en algún rincón que solo se ejecuta una vez.
[#_the_whole_script_2]
=== El script completo
[source,lua]
----
dofile("Singe/Framework.singe")
overlaySetResolution(discGetWidth(), discGetHeight())
singeSetPauseKeyEnabled(false)
local SHIP_Y = 300
local ship = spriteLoad(DIR .. "art/spaceship.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_16]
=== Qué acaba de pasar
[source,lua]
----
if singeHasGpu() then
gui = guiNew(overlayGetWidth(), overlayGetHeight())
page = guiLoad(gui, DIR .. "pause.rml")
----
Toda la GUI se construye dentro de ese `if`, y `gui` se queda en `nil` en una
máquina sin GPU. Cada llamada que toca la GUI está protegida con
`if gui ~= nil`, y la pausa sigue funcionando sin menú. Son cuatro líneas de
más, y son la diferencia entre «no tiene menú en esa máquina» y «no arranca en
esa máquina».
[source,lua]
----
guiHide(gui, page)
guiSetInput(gui, false)
----
`guiLoad` muestra el documento que ha cargado, así que una página que tiene que
empezar escondida hay que esconderla enseguida. En una GUI nueva la entrada ya
está apagada, pero apagarla aquí hace que `openMenu` y `closeMenu` sean
exactamente la una el reflejo de la otra, y las parejas que se reflejan así son
mucho más difíciles de estropear.
[source,lua]
----
guiSetHandler(gui, page, "restart", "click", function()
score = 0
shipX = 0
closeMenu()
end)
----
El manejador es una función sin nombre, escrita donde se usa. Lua te deja
escribir `function(...) ... end` en cualquier sitio donde se espera un valor, y
como `guiSetHandler` espera una función, puedes darle una en el momento en
lugar de ponerle nombre primero y pasar el nombre. Llega a `score`, `shipX` y
`closeMenu` porque están declarados encima de ella en el mismo archivo, y esa es
justamente la razón de que los manejadores se preparen debajo de las cosas que
tocan.
[source,lua]
----
if not paused then
shipX = shipX + speed
----
El indicador de pausa detiene el disco y los sonidos, no tus cuentas.
`onOverlayUpdate` se sigue llamando -- de eso se trata, porque si no el menú no
se podría dibujar -- así que lo tuyo que tenga que pararse durante la pausa lo
tienes que parar tú.
[#_try_it_20]
=== Pruébalo
. *Añade un cuarto botón.* Copia la línea `<button id="quit">`, dale un id nuevo
y un texto nuevo, y añádele un manejador. Fíjate en que no ha hecho falta
volver a medir ni desplazar nada.
. *Cámbiale el estilo.* En el bloque `<style>`, cambia `#frame` por
`width: 260dp; margin: 20dp;` y mira cómo el panel se mueve y el texto se
vuelve a repartir en líneas él solo.
. *Quita el `guiSetInput(gui, true)` de `openMenu`.* La página sigue
apareciendo. Averigua, solo con lo que ves en la pantalla, qué has perdido.
. *Deja la página a la vista todo el tiempo.* Borra las llamadas a `guiHide`,
cambia las dos llamadas `guiSetInput(gui, false)` a `true` y dibuja la GUI en
cada fotograma en lugar de solo durante la pausa. Luego pulsa las teclas de
flecha e Intro mientras vuela la nave. La página se las queda: el foco se
mueve, el deslizador cambia la velocidad e Intro pulsa el botón que tenga el
foco. Un juego que se manejara con esas teclas no las oiría nunca. Por eso un
HUD lleva `guiSetInput(gui, false)`.
. *Pon la puntuación en la página en lugar de en el HUD.* Llama a `guiSetValue`
para `score` en cada fotograma en lugar de solo en `openMenu`, y borra la
línea de `fontPrint`. Luego decide cuál de las dos prefieres de verdad, y por
qué.
[#_break_it_on_purpose_19]
=== Rómpelo a propósito
Cambia un id en `pause.rml`, de `id="resume"` a `id="continue"`, y deja el
script como está. Singe arranca y muere:
----
52:guiSetHandler: No element "resume" in document 0 of GUI 0.
----
Léelo como lees cualquier error del motor. La línea, la llamada que se ha
quejado y luego la queja -- y esta queja es precisa. No dice que el documento
esté roto; el documento se ha cargado perfectamente. Dice que has pedido un
elemento por un nombre que no tiene nada de lo que hay en él.
`guiSetHandler` y `guiSetValue` mueren las dos con un id que no existe, porque
un manejador que querías poner y no pusiste es un fallo del que quieres
enterarte en el acto. `guiGetValue` es la excepción: responde `nil`, así que
puedes preguntar si algo está ahí.
El otro error que vale la pena conocer es el que te sale cuando el propio
documento no se carga. Cambia el nombre de `pause.rml` y ejecútalo. Esta vez
Singe nombra el archivo y cita el motivo que da RmlUi, y las quejas de RmlUi
sobre tu marcado y tus estilos van a la consola tanto si la carga sale bien
como si no. Lee esa consola la primera vez que una página salga en blanco;
normalmente ya habrá dicho por qué.
[#_what_you_learned_20]
=== Lo que has aprendido
* Una GUI es un rectángulo de tamaño fijo en el que se maquetan y se dibujan
documentos; `guiNew` crea una y `guiDelete` la libera.
* `guiLoad` pone un documento RML en una GUI y lo muestra; `guiHide`, `guiShow`
y `guiClose` lo gestionan después.
* `guiDraw` compone la GUI sobre el overlay durante un fotograma, así que va en
`onOverlayUpdate` como todas las demás llamadas de dibujo.
* Una GUI nueva solo muestra; `guiSetInput` es lo que la hace usable.
* `guiSetHandler` ejecuta tu función cuando se hace clic en un elemento o se
cambia, `guiSetValue` escribe en un elemento y `guiGetValue` lee uno.
* Un evento que ha usado un elemento nunca llega a tus propios callbacks; todo
lo demás pasa como siempre.
* `Singe/gui.rcss` es el tema incluido; enlázalo primero y sobrescríbelo en un
bloque `<style>` o en una hoja tuya.
* Una GUI necesita una GPU, así que pregunta a `singeHasGpu()` si tu juego
tiene que funcionar sin ella.
* Cada GUI se renderiza en cada fotograma la dibujes o no, así que borra las que
ya no necesites.
* Una puntuación en la esquina son dos líneas de `fontPrint`, no un documento.
[#_next_time_20]
=== La próxima vez
Hay un tipo más de texto en pantalla, y tiene su propio sistema por un motivo
que no tiene nada que ver con su aspecto. La lección veintiuno trata de los
subtítulos, y de los jugadores que no pueden jugar a tu juego sin ellos.