523 lines
19 KiB
Text
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.
|