singe/docs/es/lessons/28-online.adoc

569 lines
23 KiB
Text

[#_lesson_28_online]
== Lección 28: En línea
image::learn/28-online.png[La lección terminada, 480]
En la lección doce guardaste un récord. Sigue ahí a la mañana siguiente, que es
casi todo para lo que sirve un récord, pero es un récord de una sola máquina.
Esta lección pone una puntuación en una tabla de récords que otras personas
pueden ver.
También es la lección sobre los fallos. Todo lo demás que has escrito falla por
motivos que puedes encontrar: falta un archivo, un número está mal, una función
está mal escrita. La red falla por motivos que nadie en la habitación puede ver.
Alguien desenchufó un cable en un edificio en el que nunca has estado, y tu
juego tiene que seguir funcionando. Casi todo lo que viene ahora trata de eso.
[#_what_the_service_is]
=== Qué es el servicio
Singe puede hablar con un *servicio maestro*, un servidor que guarda tres
cosas:
* una *cuenta*, que pertenece a la máquina y no a ningún juego en concreto;
* un *catálogo* de juegos para descargar y actualizar;
* *tablas de récords*, una o más por juego.
El servicio por defecto es `master.singeengine.com`, y un operador que tenga el
suyo propio apunta la máquina a otro sitio. La sección del manual sobre el
servicio en línea describe las dos partes.
Ahora la parte que la gente se salta. El servicio *no* es multijugador: no hay
forma de que dos jugadores estén en la misma partida. No es una nube de
partidas guardadas; la partida guardada de la lección doce se queda en la
máquina. No es una protección anticopia, y nada en él impide que nadie juegue a
tu juego. No es obligatorio: un juego que nunca inicia sesión funciona
exactamente igual que siempre, que es el único valor por defecto sensato para
algo que necesita una conexión que funcione.
Así que la tabla de récords es un extra. Conserva el récord local de la lección
doce, sigue mostrándolo y deja que la tabla en línea sea eso que a veces está.
[#_signing_in_happens_once_and_not_in_your_game]
=== La sesión se inicia una vez, y no en tu juego
La cuenta pertenece a la máquina. Se crea, y se inicia sesión con ella, desde el
menú incluido, detrás de la tecla asignada a `INPUT_SERVICE`, en la herramienta
llamada *Online Account*. El token que obtiene se escribe en `master.dat` en la
raíz de datos, un nivel por encima de la carpeta de datos de cada juego, así que
todos los juegos de la máquina publican sus puntuaciones con la misma sesión.
Tu juego nunca pide una dirección de correo electrónico y nunca pide una
contraseña. No hay ninguna función para hacerlo, y es a propósito: un jugador
que escribe una contraseña en un juego que ha descargado no tiene forma de saber
adónde va.
Lo que aparece de un jugador en una tabla de récords es un *alias*: de tres a
veinte caracteres entre letras, dígitos, guiones y guiones bajos, empezando y
terminando con una letra o un dígito. Sin espacios, sin puntos y sin `@`, así
que un alias ni siquiera puede parecer una dirección de correo electrónico. Se
guarda aparte de la cuenta, es lo único de un jugador que ven los demás
jugadores, y se elige una vez -- un operador puede cambiarlo, un jugador no,
porque una tabla de récords cuyos nombres no paran de cambiar no es realmente la
historia de nadie. Hasta que se ha elegido un alias, las puntuaciones se
rechazan, ya que una puntuación sin el nombre de nadie no está en una tabla de
récords en ningún sentido útil.
La herramienta Online Account muestra el nombre con el que publica esta
máquina, y tu juego lo lee con `scorePlayerName()`, que responde `nil` cuando
no hay ninguno. Conviene saberlo antes de que te desconcierte: una puntuación
enviada sin alias detrás la rechaza el *servicio*, no `scoreSubmit`, así que a
tu juego no se le dice nada. Si `scorePlayerName()` es `nil`, dilo en pantalla
mientras todavía haya alguien delante para leerlo.
[#_your_game_needs_an_id]
=== Tu juego necesita un identificador
Una tabla de récords tiene que guardarse bajo algo. Ese algo es `GAME_ID` en la
entrada de tu juego en `games.dat`, el archivo que hiciste en la lección trece:
[source,lua]
----
GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10"
----
Es un UUID, exactamente con esa forma: ocho dígitos hexadecimales, tres grupos
de cuatro y luego doce, separados por guiones. Genera uno como quieras y pégalo;
no importa cuál te salga, solo que no cambie nunca más. La entrada de la
referencia para `singeGetGameId` explica por qué no se acepta nada más.
`singeGetGameId()` lo devuelve, o `nil` cuando la entrada no tiene ninguno. Un
juego sin identificador se puede jugar pero no puede entrar en ninguna
clasificación, y preguntar primero es la forma de no ofrecer una tabla de
récords que no puede funcionar nunca:
[source,lua]
----
if singeGetGameId() then
-- showLeaderboardButton es una función tuya, no una de las de Singe.
showLeaderboardButton()
end
----
[#_the_six_calls]
=== Las seis llamadas
Todo lo que hace un juego con el servicio son seis funciones, y vienen de
`Singe/Framework.singe`, así que aquí el `dofile` del principio de tu script no
es opcional:
[source,lua]
----
dofile("Singe/Framework.singe")
----
`scoreBegin()` dice que empieza una partida. `scoreSubmit(value)` pone una
puntuación en la cola. `scoreBoard(onDone)` trae la tabla de récords.
`scorePlayerName()` es el alias. `scoreWaiting()` es cuántas puntuaciones no
han salido todavía. Y `scoreUpdate()` tiene que ejecutarse una vez por
fotograma, desde `onOverlayUpdate`, o ninguna de las demás termina nunca.
Esa última es todo el truco de esta lección, así que se dice dos veces. Nada de
esto habla con la red cuando lo llamas. `scoreSubmit` escribe en una cola y
vuelve enseguida. `scoreBoard` recuerda lo que quieres y vuelve enseguida. El
trabajo de verdad se hace a trocitos dentro de `scoreUpdate`, entre tus
fotogramas, y por eso tu juego nunca da tirones mientras la conexión va lenta.
Quita `scoreUpdate` y nada se rompe, nada da error, y nunca pasa nada.
[source,lua]
----
function onOverlayUpdate()
scoreUpdate()
overlayClear()
return OVERLAY_UPDATED
end
----
[#_posting_a_score]
=== Publicar una puntuación
Al final de una partida:
[source,lua]
----
if scoreSubmit(score) then
fetchBoard()
else
saying = "Kept here only: " .. whyNotPosted()
end
----
`scoreSubmit` responde `false` cuando no hay cuenta o el juego no tiene
`GAME_ID`, y `true` cuando la puntuación ha entrado en la cola. Fíjate en lo que
*no* hace el `else`: no tira la puntuación, no se deshace en disculpas y no
impide que el jugador empiece otra partida. Dice una línea corta, porque el
jugador no ha hecho nada mal y no puede arreglarlo desde aquí.
La cola es la razón de que esto sea seguro. Una puntuación en la cola se escribe
en disco, así que sobrevive a que se apague la máquina, y se entrega al servicio
la próxima vez que haya conexión -- una hora después, o el martes que viene. Por
eso una recreativa en un local con un wifi malo acaba igualmente en la tabla de
récords.
Sé sincero contigo mismo sobre el límite de esa promesa: la cola guarda
puntuaciones de una máquina que *tiene* cuenta. Si nadie inició sesión nunca,
`scoreSubmit` responde `false` y la puntuación no se guarda para más tarde,
porque no hay nadie para quien guardarla. `scoreWaiting()` te dice cuántas
quedan en la cola, y mostrar ese número en algún sitio es un detalle: es la
diferencia entre «el servicio está roto» y «tres de tus puntuaciones están
esperando a que vuelva internet».
`scoreBegin()` merece un párrafo propio. Llámala cuando empiece una partida, y
el servicio apunta el momento según su propio reloj. La puntuación que venga
después lleva cuánto duró la partida, medido en el servidor, que es el único
número de un envío que no ha pasado por la máquina del jugador. No se rechaza
nada por él: se le muestra a quien gestiona el servicio, junto a la puntuación,
y decide una persona. Una máquina que estaba sin conexión cuando empezó la
partida no registra ningún tiempo, lo cual es sincero, no un fallo.
Y ya que estamos siendo sinceros: una puntuación enviada es una *afirmación*. El
juego se ejecuta en el ordenador del jugador, así que nada que pueda hacer el
cliente la convierte en algo más, y una clave secreta incluida en tu juego solo
aparentaría seguridad. El servicio registra quién afirmó qué, limita lo deprisa
que pueden llegar las puntuaciones y marca las disparatadas para que las mire
una persona. Diseña tu tabla de récords sabiéndolo.
[#_reading_the_board_back]
=== Leer la tabla de récords
`scoreBoard` es la primera función de este libro que recibe una función como
argumento.
[source,lua]
----
scoreBoard(function(ok, result)
...
end)
----
Escribiste funciones en la lección cinco y les diste nombre. Esta no tiene
nombre: se escribe donde se usa, se le pasa directamente a `scoreBoard` y se
llama más tarde -- quizá segundos más tarde -- cuando llega la respuesta. Eso es
otra vez un *callback*, la misma idea que `onOverlayUpdate`, salvo que esta vez
eliges tú quién lo llama.
`ok` es `true` o `false`. Cuando es `false`, `result` es una cadena corta que
dice por qué. Cuando es `true`, `result` es una tabla con dos partes:
* `result.top`, una lista de filas, cada una con un `name` y un `value`;
* `result.standing`, el `value` y el `rank` propios de este jugador y el número
de `players` de la tabla de récords -- que puede faltar, si este jugador no
tiene nada en ella.
Entre la llamada y el callback tu juego sigue funcionando y dibujando. Ese hueco
es real y el jugador lo ve, así que pon algo en él. El script de abajo pone una
línea de texto en `"Asking the service..."` antes de llamar, y el callback
sustituye esa línea por la tabla de récords o por el motivo de que no la haya.
Una pantalla que no dice nada durante cuatro segundos parece rota; una pantalla
que dice lo que está haciendo solo es lenta.
[#_the_catalogue]
=== El catálogo
Lo tercero que hace el servicio es listar juegos. La herramienta *Get Games* del
menú muestra todo lo que ofrece el servicio, marca lo que ya está instalado en
esta máquina y lo descarga, lo actualiza o lo quita. Una descarga se comprueba
con el hash que publicó el catálogo antes de sustituir nada, así que una
transferencia que llega dañada falla en lugar de instalar un juego que no
funcionará. Un juego que el servicio ha retirado sigue funcionando y aparece
como instalado pero ya no ofrecido: lo único que deja de pasar es que reciba
actualizaciones, porque quien tiene un juego lo tiene.
Tu juego no llama a nada de esto, y no debería. El catálogo te importa como
autor de un juego más que como escritor de un script: es adonde va tu archivo
`.game` cuando lo publicas, que es la lección treinta, y es otra razón por la
que tu `games.dat` necesita un `GAME_ID` que no cambie nunca. Si alguna vez
quieres la lista desde dentro de un script, `Singe/Master.singe` es el módulo
que hay debajo de todo esto y merece la pena leerlo una vez.
[#_what_the_service_knows_about_a_player]
=== Lo que el servicio sabe de un jugador
Si publicas un juego que envía puntuaciones, le estás pasando datos de otra
persona a un servidor, y eso es responsabilidad tuya aunque no hayas escrito el
servidor. Así que dilo claramente, en la pantalla de información o en el léeme
de tu propio juego:
* El servicio guarda una *dirección de correo electrónico* y una contraseña para
la cuenta. La dirección sirve para recuperar una contraseña perdida y para
nada más. Nunca se muestra a otros jugadores y no se puede cambiar después.
* El *alias* es lo que aparece en las tablas de récords. Se mantiene aparte de
la cuenta a propósito para que una dirección no sea nunca lo que ven los demás
jugadores, y se comprueba contra una lista de nombres que nadie debería
reclamar y de palabras que no pintan nada en una pantalla en un local público.
* Una *puntuación* lleva su valor, la tabla de récords en la que está, la hora
que registró el servicio y el alias. Nada más.
* El catálogo sabe *qué juegos están instalados* en la máquina, y qué versión,
porque así es como te ofrece una actualización.
Lo que no hace nada de esto es seguir a un jugador de un juego a otro, vender
nada ni meterse en la máquina a buscar algo que tú no hayas enviado. Tu juego no
debería añadir nada a la lista. Si adjuntas información extra a una
puntuación -- `scoreSubmit` acepta un tercer argumento opcional para eso --
que trate del juego.
[#_the_script]
=== El script
Una prueba de reflejos: espera a la palabra, pulsa el botón y puntúa lo que
quede de mil. Es un juego pequeño, y el resto es la parte en línea.
Crea una carpeta llamada `reaction` y pon esto en `reaction.singe`, dentro de
ella. Dale a la carpeta un `games.dat` como el de la lección trece, que nombre
`reaction/reaction.singe`, con la línea `GAME_ID` de antes en su entrada.
[source,lua]
----
dofile("Singe/Framework.singe")
local WAIT_MIN = 1000
local WAIT_MAX = 3000
local PERFECT = 1000
local state = "attract"
local goAt = 0
local score = 0
local best = saveGet("best", 0)
local saying = "Button 1 to play."
local top = nil
local standing = nil
local function whyNotPosted()
if not singeGetGameId() then
return "this game has no GAME_ID."
end
return "this machine is not signed in."
end
local function fetchBoard()
top = nil
standing = nil
saying = "Asking the service..."
scoreBoard(function(ok, result)
if not ok then
saying = "No board: " .. tostring(result)
return
end
top = result.top
standing = result.standing
if #top == 0 then
saying = "The board is empty. You could be first."
else
saying = ""
end
end)
end
local function finish(reaction)
score = PERFECT - reaction
if score < 0 then
score = 0
end
if score > best then
best = score
saveSet("best", best)
end
state = "result"
if scoreSubmit(score) then
fetchBoard()
else
saying = "Kept here only: " .. whyNotPosted()
end
end
local function play()
state = "waiting"
goAt = singeGetTicks() + math.random(WAIT_MIN, WAIT_MAX)
score = 0
top = nil
standing = nil
saying = ""
scoreBegin()
end
function onInputPressed(what)
if what ~= SWITCH_BUTTON1 then
return
end
if state == "waiting" then
state = "result"
score = 0
saying = "Too soon. Nothing posted."
elseif state == "go" then
finish(singeGetTicks() - goAt)
else
play()
end
end
function onOverlayUpdate()
scoreUpdate()
if state == "waiting" and singeGetTicks() >= goAt then
state = "go"
end
overlayClear()
overlayPrint(2, 0, "REACTION TEST")
if state == "attract" then
overlayPrint(2, 2, "Button 1 to play.")
elseif state == "waiting" then
overlayPrint(2, 2, "Wait for it...")
elseif state == "go" then
overlayPrint(2, 2, "NOW!")
else
overlayPrint(2, 2, "You scored " .. score .. ". Button 1 plays again.")
end
overlayPrint(2, 4, "Best on this machine: " .. best)
overlayPrint(2, 5, "Posting as: " .. (scorePlayerName() or "nobody yet"))
overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting())
overlayPrint(2, 8, saying)
if top then
for place, row in ipairs(top) do
if place > 5 then
break
end
overlayPrint(2, 9 + place, place .. ". " .. row.name .. " " .. row.value)
end
end
if standing then
overlayPrint(2, 16, "You are " .. standing.rank .. " of " .. standing.players .. ".")
end
return OVERLAY_UPDATED
end
----
Ejecútalo con `Singe -R reaction`. En una máquina que nunca ha iniciado sesión,
se juega, guarda tu mejor puntuación y dice
`Kept here only: this machine is not signed in.` Esa es la ejecución importante, y es la que tendrán la mayoría de tus jugadores.
[#_what_just_happened_23]
=== Qué acaba de pasar
[source,lua]
----
local top = nil
local standing = nil
----
Dos variables que son `nil` casi todo el tiempo. `nil` significa «aquí no hay
nada», y es lo que contiene una variable antes de que se meta nada en ella.
Estas guardan la tabla de récords cuando la hay, y `nil` cuando no: mientras la
petición está en camino, después de un fallo y antes de la primera partida. El
código de dibujo pregunta `if
top then` y no dibuja nada cuando no hay nada, que es la forma de casi cualquier
trozo de código de red que escribas.
[source,lua]
----
saying = "Asking the service..."
scoreBoard(function(ok, result)
----
El orden importa. La línea de texto se pone *antes* de la llamada, porque la
llamada vuelve enseguida y el callback puede tardar segundos en ejecutarse. Si
pones el texto solo dentro del callback, la pantalla muestra el mensaje de la
partida anterior hasta que llega la respuesta, que es exactamente el mensaje
equivocado en exactamente el momento equivocado.
[source,lua]
----
if not ok then
saying = "No board: " .. tostring(result)
return
end
----
El fallo va primero, y es corto. `tostring` convierte lo que haya vuelto en
texto para que unirlo con `..` no pueda fallar; `result` es una cadena cuando
`ok` es false, pero una línea de código de dibujo que se fía del tipo de algo
que le ha llegado de un servidor es una línea esperando a romperse. El jugador
ve una frase y el juego sigue.
[source,lua]
----
if scoreSubmit(score) then
fetchBoard()
else
saying = "Kept here only: " .. whyNotPosted()
end
----
Que `scoreSubmit` responda `false` no es un error. Es un hecho sobre esta
máquina, y merece la pena distinguir los dos hechos que puede significar, que es
lo que hace `whyNotPosted`: que falte `GAME_ID` es error *tuyo*, y una máquina
sin sesión iniciada no es error de nadie. La mejor puntuación local ya se
guardó, dos líneas más arriba, antes de intentar nada de esto. Primero guarda,
luego publica, siempre.
[source,lua]
----
overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting())
----
La cola, en pantalla. En una máquina con conexión esto marca `0` para siempre y
nadie se fija. En una máquina sin ella sube, y el jugador sabe que el juego le
está guardando las puntuaciones en lugar de comérselas.
[source,lua]
----
function onOverlayUpdate()
scoreUpdate()
----
La primera línea del fotograma, antes que nada. Ponla en algún sitio donde un
`if` no pueda saltársela, porque un `scoreUpdate` que solo se ejecuta en la
pantalla de fin de partida es una cola que solo se vacía en la pantalla de fin
de partida.
[#_try_it_28]
=== Pruébalo
. *Desenchúfalo.* Desconecta la máquina de la red, juega unas cuantas partidas
y mira el número `Waiting to send`. Vuelve a conectarla y mira cómo baja sin
que hagas nada.
. *Borra el identificador.* Quita `GAME_ID` de tu `games.dat` y vuelve a
ejecutarlo. El mensaje cambia. Vuelve a ponerlo.
. *Muestra más de la tabla de récords.* El bucle se para en cinco filas. El
overlay mide dieciocho filas de caracteres de alto por defecto, así que
calcula cuántas te caben de verdad, y mueve la línea de `standing` si hace
falta.
. *Ponle nombre a la tabla.* `scoreSubmit(score, "reaction")` y
`scoreBoard(onDone, "reaction")` usan una tabla de récords con ese nombre en
lugar de la que lleva el nombre de tu script. Publica en dos tablas desde el mismo juego -- por ejemplo,
una para la puntuación y otra para la reacción más rápida -- y deja que el
botón 2 cambie cuál se muestra.
. *Haz visible la espera.* Entre la llamada a `scoreBoard` y la ejecución del
callback, dibuja un punto que se mueva. Luego averigua cuánto dura eso de
verdad con tu conexión, y si merecía la pena dibujarlo.
[#_break_it_on_purpose_27]
=== Rómpelo a propósito
Quita la primera línea de `onOverlayUpdate`, para que desaparezca la llamada a
`scoreUpdate()`, y juega una partida.
No pasa nada. Ni error, ni mensaje, ni tabla de récords, y el número
`Waiting to send` sube y se queda arriba para siempre. El juego funciona
perfectamente.
Este es el peor tipo de fallo y merece la pena encontrárselo una vez a
propósito: un paso que falta y que produce silencio en lugar de una queja. No
hay número de línea que mirar, porque no ha ido nada mal -- pediste trabajo y
luego nunca le diste ocasión de hacerse. Cuando algo de Singe que recibe un
callback no te devuelve nunca la llamada, la primera pregunta es siempre si lo
que lo mueve se está ejecutando en cada fotograma.
El otro que te encontrarás hace más ruido. Vuelve a poner `scoreUpdate()`, borra
la línea `dofile` del principio y ejecútalo:
----
Error executing function 'onOverlayUpdate': reaction/reaction.singe:89: attempt to call a nil value (global 'scoreUpdate')
stack traceback:
reaction/reaction.singe:89: in function 'onOverlayUpdate'
----
La línea 89 es el `scoreUpdate()` que volviste a poner, una línea más arriba
ahora que el `dofile` ya no está. Lee la primera línea como siempre: la función a la que estaba llamando el motor, el
archivo, la línea y la queja. `nil` otra vez -- el nombre `scoreUpdate` no tiene
nada dentro, porque el archivo que lo define nunca se cargó. Las seis llamadas
viven en `Singe/Framework.singe`.
[#_what_you_learned_28]
=== Lo que has aprendido
* El servicio guarda una cuenta, un catálogo y tablas de récords. No es
multijugador, no es una nube de partidas guardadas y nunca es obligatorio.
* La cuenta pertenece a la máquina y se inicia sesión con ella desde las
herramientas de servicio del menú. Un juego nunca pide una contraseña.
* Una tabla de récords se guarda bajo el `GAME_ID` de `games.dat`, un UUID que
no debe cambiar nunca. `singeGetGameId()` lo lee.
* `scoreSubmit` pone una puntuación en la cola en disco y vuelve enseguida; la
cola sobrevive a que se apague la máquina.
* `scoreUpdate()` tiene que ejecutarse en cada fotograma o nada de lo que hace
el servicio termina nunca.
* `scoreBoard` le da su respuesta a una función que escribes tú, que se ejecuta
más tarde. Dibuja algo mientras tanto.
* Cualquier llamada de red puede fallar, y fallar no es un error. Di una línea
corta y sigue jugando.
* Una puntuación publicada es una afirmación, y el servicio la trata como tal.
* Conserva el récord local. Es el que siempre funciona.
[#_next_time_28]
=== La próxima vez
Un récord es un número, y la partida guardada de la lección doce es el sitio
adecuado para él. La lección veintinueve es para todo lo demás que un juego
quiere guardar: cada ronda que juega cualquiera, las fases que incluyes en el
juego y las preguntas que quieres hacerles a las dos cosas. Coge esta prueba de
reflejos y le da una base de datos.