singe/docs/es/lessons/29-databases.adoc

523 lines
19 KiB
Text

[#_lesson_29_keeping_data]
== Lección 29: Guardar datos
image::learn/29-databases.png[La lección terminada, 480]
La lección doce guardaba un récord: un número, bajo un nombre, ahí a la mañana
siguiente. Eso es justo lo adecuado para un récord, y lo inadecuado para casi
todo lo demás que un juego quiere conservar. Una lista de todas las partidas
que haya jugado cualquiera. El mejor tiempo en cada una de veinte pistas.
Cuarenta niveles que alguien diseñó en una hoja de cálculo. La pregunta «¿cuál
es mi mejor marca en la fase difícil?», que un montón de números guardados no
puede responder sin que escribas tú la respuesta a mano.
Para eso está una base de datos, y Singe trae una incorporada. Esta lección toma
la prueba de reflejos de la lección veintiocho, le da fases que vienen con el
juego y hace que guarde cada partida que juegue cualquiera y responda preguntas
sobre ellas.
[#_what_a_database_is]
=== Qué es una base de datos
Una base de datos es un archivo de *tablas*. Una tabla son filas y columnas,
como una hoja de cálculo con los nombres de las columnas decididos de antemano:
una tabla de partidas podría tener una fase, una puntuación y una hora en cada
fila. Metes filas y haces preguntas para sacar filas, y las preguntas se
escriben en *SQL*, un lenguaje pequeño que lleva haciendo este trabajo desde
antes que la mayoría de los lenguajes de programación de los que has oído
hablar.
La base de datos que lleva Singe es *SQLite*. No es un servidor que haya que
instalar ni un servicio en el que darse de alta. Es una biblioteca, la base de
datos es un archivo normal y corriente, y el mismo SQLite está en tu móvil, en
tu navegador y seguramente en tu coche. No hace falta saber SQL para seguir esta
lección; cada sentencia se explica según va pasando, y las tres que más usarás
-- `CREATE TABLE`, `INSERT` y `SELECT` -- están todas aquí.
[#_opening_one]
=== Abrir una
[source,lua]
----
local sqlite3 = require("sqlite3")
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
----
`require("sqlite3")` carga una biblioteca que viene con Singe y te la devuelve
como una tabla, así que todo lo que sabe hacer SQLite es `sqlite3.` algo.
`sqlite3.open` abre un archivo de base de datos y lo crea si todavía no existe,
así que la primera vez que se ejecuta tu juego crea uno vacío y todas las veces
siguientes lo vuelve a encontrar.
El archivo va en `singeGetDataPath()`, por la misma razón que la partida
guardada de la lección doce: la carpeta del propio juego puede estar en un
disco, en una carpeta compartida o empaquetada en un solo archivo, y en ninguno
de esos sitios se puede escribir. Todo lo que escribe un juego va en su carpeta
de datos.
[#_making_a_table]
=== Crear una tabla
Una base de datos vacía no tiene tablas. Creas una describiéndola:
[source,lua]
----
runs:exec([[
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
stage TEXT NOT NULL,
score INTEGER NOT NULL,
at INTEGER NOT NULL
)
]])
----
`exec` ejecuta SQL que no devuelve nada. Los dobles corchetes son la cadena
larga de Lua: todo lo que hay entre ellos, saltos de línea incluidos, es una
sola cadena, y así es como mantienes el SQL legible.
`CREATE TABLE` nombra la tabla y sus columnas, cada una con un tipo: `TEXT` para
palabras e `INTEGER` para números enteros. `NOT NULL` significa que todas las
filas tienen que tener un valor ahí. `INTEGER PRIMARY KEY` es especial: SQLite
la rellena solo, contando hacia arriba, así que cada fila recibe su propio
número sin que tengas que pensar en ello.
`IF NOT EXISTS` es lo que hace que sea seguro ejecutar esto cada vez que arranca
el juego. La primera vez, crea la tabla. Todas las veces siguientes, la tabla ya
está ahí y la línea no hace nada. Sin esas tres palabras, la segunda vez que
ejecutaras tu juego se pararía con un error diciendo que la tabla ya existe.
[#_putting_a_row_in]
=== Meter una fila
Podrías construir el SQL de una fila nueva pegando cadenas:
[source,lua]
----
runs:exec("INSERT INTO runs (stage, score, at) VALUES ('" .. stage .. "', " .. score .. ", " .. os.time() .. ")")
----
No lo hagas. Funciona hasta que un nombre lleva un apóstrofo, y en ese momento
la comilla termina la cadena antes de tiempo y la sentencia no tiene ni pies ni
cabeza. En un juego que deja escribir a los jugadores es peor: lo que escriban
pasa a formar parte de tu SQL.
La forma correcta es escribir la sentencia una vez, con signos de interrogación
donde van los valores, y pasar los valores aparte:
[source,lua]
----
local addRun = runs:prepare("INSERT INTO runs (stage, score, at) VALUES (?, ?, ?)")
local function record(stage, points)
addRun:bind_values(stage, points, os.time())
addRun:step()
addRun:reset()
end
----
`prepare` convierte el SQL en una *sentencia* que se puede usar una y otra vez.
`bind_values` pone un valor en cada signo de interrogación, en orden. Contenga
lo que contenga un valor, apóstrofos incluidos, es un valor y nunca parte del
SQL. `step` ejecuta la sentencia, y `reset` la deja lista para ejecutarse otra
vez. `os.time()` es Lua normal: la hora actual como un número de segundos, que
es la forma más fácil de guardar «cuándo».
Preparar una vez y reutilizar la sentencia también es más rápido, porque SQLite
solo calcula una vez cómo ejecutarla. Para un juego que escribe una fila por
minuto da igual. Para un editor de niveles que escribe mil, no.
[#_asking_questions]
=== Hacer preguntas
Las filas vuelven a salir con `nrows`, que ejecuta un `SELECT` y te da cada fila
como una tabla con los nombres de las columnas como claves:
[source,lua]
----
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC LIMIT 5") do
debugPrint(row.stage .. " " .. row.score)
end
----
Lee el SQL de izquierda a derecha. `SELECT stage, score` dice qué columnas
quieres. `FROM runs` dice de qué tabla. `ORDER BY score DESC` ordena las filas
por puntuación, de mayor a menor, y `LIMIT 5` se para después de cinco. Eso es
una tabla de récords, y no ha necesitado ni una línea tuya de código para
ordenar.
Las preguntas pueden ser más listas que las que querrías escribir a mano:
[source,lua]
----
for row in runs:nrows("SELECT stage, MAX(score) AS top, COUNT(*) AS tries FROM runs GROUP BY stage ORDER BY stage") do
debugPrint(row.stage .. ": best " .. row.top .. " of " .. row.tries)
end
----
`GROUP BY stage` reúne las filas en un grupo por fase, y `MAX(score)` y
`COUNT(*)` se calculan para cada grupo: la mejor puntuación de esa fase y
cuántas veces se ha jugado. `AS top` le pone nombre a la respuesta, que es la
clave con la que la vuelves a leer.
Cuando una pregunta tiene una sola respuesta, `urows` te devuelve las columnas
mismas en lugar de una tabla, que queda más limpio para un recuento:
[source,lua]
----
for count in runs:urows("SELECT COUNT(*) FROM runs") do
played = count
end
----
[#_ask_when_it_changes]
=== Pregunta cuando cambie
Ninguna de esas preguntas va en `onOverlayUpdate`. Una consulta es rápida, pero
sesenta por segundo, para obtener respuestas que cambian una vez por minuto, es
el juego haciendo la misma pregunta y recibiendo la misma respuesta cincuenta y
nueve veces de cada sesenta.
Así que el juego pregunta cuando la respuesta puede haber cambiado -- una vez al
principio, y otra cada vez que se registra una partida -- y guarda lo que le
llega en tablas normales:
[source,lua]
----
local function refresh()
best = {}
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC, id LIMIT 5") do
best[#best + 1] = row
end
-- y lo mismo para las otras dos preguntas
end
----
`onOverlayUpdate` dibuja a partir de `best`, que no cuesta nada. `record` llama
a `refresh` después de cada fila nueva, así que la tabla de la pantalla nunca
está desfasada.
[#_data_that_ships_with_the_game]
=== Datos que vienen con el juego
La otra mitad de los datos de un juego es la parte que escribes antes de que
juegue nadie: las fases, los niveles, los objetos, las líneas de diálogo. Eso
también puede ser una base de datos, hecha con cualquier herramienta de SQLite
-- el programa `sqlite3` en la línea de comandos, o un programa con ventanas
como DB Browser for SQLite -- y puesta en la carpeta de tu juego junto a su
arte. El kit de arte trae una, `stages.db`, con un nombre y la espera más corta
y la más larga de cada fase.
Esa la abre un juego de otra manera:
[source,lua]
----
local stages = {}
local shipped = sqlite3.open(DIR .. "art/stages.db", sqlite3.OPEN_READONLY)
for row in shipped:nrows("SELECT name, waitMin, waitMax FROM stages ORDER BY id") do
stages[#stages + 1] = row
end
shipped:close()
----
`DIR` es la carpeta de tu juego, donde está el arte. `sqlite3.OPEN_READONLY`
dice que solo piensas leerla, y eso importa el día que publiques. Cuando
empaquetes tu juego en un solo archivo en la próxima lección, una base de datos
abierta de solo lectura se lee directamente de ese archivo, sin desempaquetar
nada. Una abierta para escritura se copia, una vez, a la carpeta de datos del
juego, y a partir de entonces lo que cambias es la copia; en el archivo que
publicaste no se escribe nunca.
Así que mantén separados los dos tipos. Lo que has hecho tú va en tu carpeta y
se abre de solo lectura. Lo que hace el juego va en la carpeta de datos. Aquí
las fases se leen una vez al principio en una tabla normal y la base de datos se
cierra, porque una lista de cuatro fases no merece tener un archivo abierto.
[#_closing_up]
=== Cerrar al terminar
Las sentencias y las bases de datos retienen memoria y mantienen un archivo
abierto hasta que dices que has terminado, y el sitio ordenado para decirlo es
`onShutdown`:
[source,lua]
----
function onShutdown()
addRun:finalize()
runs:close()
end
----
`finalize` es el `close` de una sentencia. Una base de datos no se cierra
mientras siga por ahí una sentencia hecha a partir de ella, así que las
sentencias van primero.
[#_the_whole_script_4]
=== El script completo
Crea una carpeta llamada `runs`, copia dentro la carpeta `art` del libro para
tener `stages.db`, y pon esto en `runs.singe`:
[source,lua]
----
dofile("Singe/Framework.singe")
local sqlite3 = require("sqlite3")
local PERFECT = 1000
-- Lo que viene con el juego: las fases, leídas una vez y cerradas.
local stages = {}
local shipped = sqlite3.open(DIR .. "art/stages.db", sqlite3.OPEN_READONLY)
for row in shipped:nrows("SELECT name, waitMin, waitMax FROM stages ORDER BY id") do
stages[#stages + 1] = row
end
shipped:close()
-- Lo que escribe el juego: cada partida que juegue cualquiera, guardada en la carpeta de datos.
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
runs:exec([[
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
stage TEXT NOT NULL,
score INTEGER NOT NULL,
at INTEGER NOT NULL
)
]])
local addRun = runs:prepare("INSERT INTO runs (stage, score, at) VALUES (?, ?, ?)")
local stageAt = 1
local state = "attract"
local goAt = 0
local score = 0
local best = {}
local perStage = {}
local played = 0
-- Las preguntas, hechas cuando se añade una partida y no en cada fotograma.
local function refresh()
best = {}
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC, id LIMIT 5") do
best[#best + 1] = row
end
perStage = {}
for row in runs:nrows("SELECT stage, MAX(score) AS top, COUNT(*) AS tries FROM runs GROUP BY stage ORDER BY stage") do
perStage[#perStage + 1] = row
end
for count in runs:urows("SELECT COUNT(*) FROM runs") do
played = count
end
end
local function record(stage, points)
addRun:bind_values(stage, points, os.time())
addRun:step()
addRun:reset()
refresh()
end
local function play()
local stage = stages[stageAt]
state = "waiting"
goAt = singeGetTicks() + math.random(stage.waitMin, stage.waitMax)
score = 0
end
function onInputPressed(what)
if what == SWITCH_BUTTON2 and state ~= "waiting" and state ~= "go" then
stageAt = stageAt % #stages + 1
return
end
if what ~= SWITCH_BUTTON1 then
return
end
if state == "waiting" then
state = "result"
score = 0
record(stages[stageAt].name, score)
elseif state == "go" then
score = math.max(0, PERFECT - (singeGetTicks() - goAt))
state = "result"
record(stages[stageAt].name, score)
else
play()
end
end
function onOverlayUpdate()
if state == "waiting" and singeGetTicks() >= goAt then
state = "go"
end
overlayClear()
overlayPrint(2, 0, "REACTION TEST: " .. stages[stageAt].name)
if state == "attract" then
overlayPrint(2, 2, "Button 1 to play, button 2 for another stage.")
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, "Runs kept: " .. played)
overlayPrint(2, 6, "BEST RUNS")
for place, row in ipairs(best) do
overlayPrint(2, 6 + place, string.format("%d. %-8s %4d", place, row.stage, row.score))
end
overlayPrint(24, 6, "BY STAGE")
for line, row in ipairs(perStage) do
overlayPrint(24, 6 + line, string.format("%-8s best %4d of %d", row.stage, row.top, row.tries))
end
return OVERLAY_UPDATED
end
function onShutdown()
addRun:finalize()
runs:close()
end
refresh()
----
Ejecútalo con `Singe -R runs`. El botón 1 empieza una ronda y la detiene, como
en la lección veintiocho, y el botón 2 entre rondas pasa a la fase siguiente. Juega unas
cuantas en cada una. Luego sal y vuelve a ejecutarlo: todas las partidas siguen
ahí, las cinco mejores a la izquierda y la mejor de cada fase a la derecha.
[#_what_just_happened_24]
=== Qué acaba de pasar
[source,lua]
----
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
----
Hay dos archivos en juego, y no se parecen. `art/stages.db` es tuyo: lo
escribiste tú, viene con el juego y el juego solo lo lee. `runs.db` es del
juego: no existía hasta que la primera ejecución lo creó, vive en la carpeta de
datos y nadie lo publica.
[source,lua]
----
if state == "waiting" then
state = "result"
score = 0
record(stages[stageAt].name, score)
----
Un botón pulsado demasiado pronto también registra una partida, con una
puntuación de nada. La base de datos es un registro de lo que pasó, y una salida
en falso pasó. Si mostrar esas filas o no es cosa del `SELECT`, no algo que
esconder no escribiéndolas nunca.
[source,lua]
----
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC, id LIMIT 5") do
----
`ORDER BY score DESC, id` ordena por puntuación y, cuando dos puntuaciones son
iguales, por `id` -- el orden en que se jugaron. Sin el segundo criterio, dos
puntuaciones iguales salen en el orden que a SQLite le venga bien, que puede
cambiar de una ejecución a otra. Una tabla que se reordena sola sin motivo
visible parece rota aunque no lo esté.
[#_try_it_29]
=== Pruébalo
. *Muestra la fecha.* `os.date("%Y-%m-%d", row.at)` vuelve a convertir una hora
guardada en algo que una persona puede leer. Añade la fecha a cada una de las
mejores partidas.
. *Ignora las salidas en falso.* Añade `WHERE score > 0` a la pregunta de las
mejores partidas, entre `FROM runs` y `ORDER BY`. Luego cuenta las salidas en
falso de cada fase con una pregunta tuya.
. *Añade una fase.* Abre `stages.db` en una herramienta de SQLite, añade una
fila y ejecuta el juego. No cambia nada del script. Esa es la gracia de tener
los datos fuera del script.
. *Borra el historial.* `runs:exec("DELETE FROM runs")` vacía la tabla. Ponlo en
un botón, llama a `refresh` después y mira cómo todo se queda en nada. Este es
el «borrar récords» de un menú de servicio.
. *Escribe mil filas.* Llama a `record` mil veces en un bucle y cronométralo con
`singeGetTicks`. Luego pon `runs:exec("BEGIN")` antes del bucle y
`runs:exec("COMMIT")` después, y vuelve a cronometrarlo. Una *transacción*
convierte muchas escrituras en una, y la diferencia es lo bastante grande como
para que valga la pena recordarla.
[#_break_it_on_purpose_28]
=== Rómpelo a propósito
Quita `addRun:reset()` de `record` y juega tres rondas en tres fases distintas.
Ningún error. El juego funciona perfectamente. Pero la tabla de la derecha dice
que has jugado la primera fase tres veces, y las mejores partidas son tres
copias de tu primera puntuación. `bind_values` no podía poner valores nuevos en
una sentencia que ya se había ejecutado y no se había reiniciado, así que se
rindió -- en silencio, devolviendo un número que no estabas comprobando -- y
`step` volvió a ejecutar la sentencia con los valores que ya tenía.
Este es el fallo que importa en esta lección, porque no lo parece. Una consulta
que falla hace ruido. Una escritura que escribe lo que no es no hace ninguno, y
la encuentras una semana después en los datos. Cuando las filas de una base de
datos no son lo que esperas, mira el código que las puso ahí antes que el código
que las lee.
Vuelve a poner `reset` y rómpelo de la forma ruidosa: cambia `score` por `scroe`
en el primer `SELECT` de `refresh`.
----
Error running script: no such column: scroe
stack traceback:
[C]: in method 'nrows'
runs/runs.singe:42: in local 'refresh'
runs/runs.singe:132: in main chunk
----
SQLite lee los nombres de las columnas cuando prepara la pregunta, así que uno
mal escrito para el juego en el acto, y el mensaje lo nombra exactamente. La
traza se lee de dentro hacia fuera: `nrows` se quejó, `refresh` la llamó en la
línea 42, y el propio script llamó a `refresh` en la línea 132, la última, al
arrancar el juego. Por eso dice *running script* en lugar de nombrar una función
que llamó el motor: nunca llegó al primer fotograma.
[#_what_you_learned_29]
=== Lo que has aprendido
* Una base de datos es un archivo de tablas; una tabla son filas con columnas
que tienen nombre.
* `require("sqlite3")` te da SQLite. `sqlite3.open` abre un archivo y lo crea si
no existe.
* Lo que escribe el juego va en `singeGetDataPath()`. Lo que publicas va en tu
carpeta, y se abre con `sqlite3.OPEN_READONLY`.
* `CREATE TABLE IF NOT EXISTS` se puede ejecutar sin peligro cada vez que
arranca el juego.
* Los valores entran con `prepare`, `bind_values`, `step` y `reset`, nunca
pegando cadenas en el SQL.
* `nrows` devuelve las filas como tablas; `urows` devuelve las columnas mismas.
* `ORDER BY`, `LIMIT`, `GROUP BY`, `MAX` y `COUNT` responden a las preguntas
para las que, si no, escribirías bucles.
* Pregunta cuando la respuesta puede cambiar, no en cada fotograma.
* En `onShutdown` se finalizan las sentencias y después se cierra la base de
datos.
[#_next_time_29]
=== La próxima vez
Tu juego tiene ya una carpeta llena de cosas: un script, arte, sonidos y una
base de datos o dos. La lección treinta convierte esa carpeta en un solo archivo
que un desconocido puede copiar en su propio ordenador y jugar -- y tu base de
datos de solo lectura va con él, abriéndose exactamente igual que ahora.