singe/docs/es/lessons/12-score.adoc

548 lines
21 KiB
Text

[#_lesson_12_score_lives_and_game_over]
== Lección 12: Puntuación, vidas y fin de partida
image::learn/12-score.png[La lección terminada, 480]
Tu juego lleva la puntuación, quita vidas y dice GAME OVER. Es casi un juego.
Todavía le faltan tres cosas. No tiene puerta de entrada: está en plena acción
en el instante en que se carga. Se olvida de todo: sal, y la mejor puntuación
que nadie haya conseguido se va con él. Y lleva la cuenta de lo que está
haciendo con dos variables de verdadero o falso, `over` y `dying`, que es la
trampa de la que te avisó la lección siete.
Esta lección arregla las tres. Una variable para lo que está haciendo el juego,
una pantalla de título desde la que empezar, un récord que mañana seguirá ahí y
una segunda partida que empieza exactamente igual que la primera.
[#_a_game_is_always_in_exactly_one_state]
=== Un juego siempre está exactamente en un estado
Observa cualquier máquina recreativa durante dos minutos. Muestra un título y
espera a alguien. Alguien pulsa start y se pone a jugar. El jugador se queda
sin vidas y la máquina lo dice, y luego vuelve a esperar. La máquina siempre
está haciendo exactamente una de esas cosas.
Dodge, en la lección siete, guardaba eso en una variable que contenía una
palabra: `"waiting"`, `"playing"` o `"over"`. El juego de la lección nueve
volvió a un `over` de verdadero o falso, y la lección diez le añadió `dying` al
lado. Cuenta lo que pueden decir dos variables así. Cada una es `true` o
`false`, así que juntas dan cuatro combinaciones. Tres significan algo:
jugando, muriendo y fin de partida. La cuarta, las dos a la vez, es un
disparate, y tu código tiene que tener cuidado de no producirla nunca. Lo
tiene: mira `endGame`, que vuelve a poner `dying` a `false` antes de poner
`over` a `true`. Ahora añade una pantalla de título. Eso es un tercer
indicador, ocho combinaciones, y cuatro de ellas disparates. Esa es
exactamente la trampa de la lección siete, y es donde los juegos pequeños van a
morir.
Así que haz lo que hizo Dodge. Cuatro estados, una variable. Pon los nombres de
los estados debajo de tus otras constantes:
[source,lua]
----
local TITLE = "title"
local PLAYING = "playing"
local DYING = "dying"
local OVER = "over"
----
Luego quita `over` y `dying` de tus variables, y pon una sola línea en su
lugar:
[source,lua]
----
local state = TITLE
----
Dodge usaba las propias palabras. Los nombres en mayúsculas no son más listos,
y no atrapan una falta de ortografía: Lua trata un nombre mal escrito como
`nil` y una palabra mal escrita como una palabra que no coincide con nada, y
cualquiera de las dos te da un juego que deja de responder sin decir nada. Lo
que te compran los nombres es una lista. Cada estado en el que puede estar el
juego está escrito una vez, arriba del todo del archivo, donde puedes ver los
cuatro de un vistazo.
Allí donde el juego ponía un indicador, ahora pone el estado. En
`updateRocks`, donde se va la última vida:
[source,lua]
----
if lives <= 0 then
state = DYING
deathChannel = channel
----
En `endGame`, dos líneas se convierten en una, y la combinación disparatada
desaparece porque ya no queda nada que combinar:
[source,lua]
----
function endGame()
deathChannel = -1
state = OVER
musicStop(theme, 1500)
end
----
Y en `startGame`, las dos líneas que borraban los dos indicadores se convierten
en `state = PLAYING`.
[#_one_place_that_decides]
=== Un solo sitio que decide
En cada fotograma, `onOverlayUpdate` toma las dos decisiones que importan: qué
se mueve y qué palabras van encima. Qué se mueve es una sola comprobación:
[source,lua]
----
if state == PLAYING then
updateShip()
updateShots()
updateRocks()
end
----
Eso sustituye a `if not over and not dying then`, y dice lo que quiere decir.
En el título, mientras suena la última explosión y cuando la partida ya ha
terminado, nada se mueve. Lo consigues no llamando a las funciones que mueven
cosas. Parar un juego no es una característica especial; es dejar cosas fuera.
El mundo se sigue dibujando en cada fotograma, sea cual sea el estado,
exactamente como lo dibujaba Dodge. Solo cambian las palabras. Sustituye la
línea `drawHud()` por esto:
[source,lua]
----
if state == TITLE then
drawTitle()
else
drawHud()
end
----
`drawTitle` es nueva, y va después de `drawStars`:
[source,lua]
----
function drawTitle()
overlayPrint(25, 6, "R O C K S")
overlayPrint(25, 9, "BEST " .. best)
overlayPrint(22, 12, "PRESS 1 TO PLAY")
end
----
`best` es la mejor puntuación hasta ahora, y llega en la sección siguiente; el
juego no funcionará hasta que llegue. Antes de la primera partida la lista de
rocas está vacía, así que lo que muestra el título son las estrellas pasando y
la nave esperando abajo, que es todo lo que necesita una pantalla de título.
Hay una línea que tiene que irse, o no verás nunca el título. Borra el
`startGame()` del final del todo del archivo. Ahora el juego empieza en su
título, y *Volver a empezar* explica por qué importa esa línea.
`drawHud` hace una pregunta propia. Cambia su `if over then` por:
[source,lua]
----
if state == OVER then
----
El callback de entrada necesita el mismo tipo de razonamiento. Aquí lo tienes
entero:
[source,lua]
----
function onInputPressed(what)
if what == SWITCH_LEFT then
goLeft = true
elseif what == SWITCH_RIGHT then
goRight = true
elseif what == SWITCH_BUTTON1 and state == PLAYING then
shots[#shots + 1] = { x = shipX + shipWidth / 2 - shotWidth / 2, y = shipY }
soundPlay(shootClip, 0, 35)
elseif what == SWITCH_START1 and (state == TITLE or state == OVER) then
soundPlay(clickClip)
startGame()
end
end
----
Léelo desde arriba. Las flechas se apuntan sea cual sea el estado, porque solo
dicen qué teclas están pulsadas, y nada mueve la nave a menos que el estado sea
`PLAYING`. Disparar solo significa algo mientras se juega. Y `1` empieza una
partida desde el título o desde la pantalla de fin de partida, y no mientras la
última explosión sigue sonando: la partida no ha terminado hasta que lo dice.
Los paréntesis funcionan igual que en aritmética. El `or` de dentro se resuelve
primero, y luego el `and` lo une a la tecla.
[#_score_and_lives]
=== Puntuación y vidas
Tienes las dos desde la lección nueve, y apenas cambian. Lo nuevo es la mejor
puntuación, que empieza su vida como una variable normal. Ponla con las demás,
debajo de `lives`:
[source,lua]
----
local best = 0
----
Muéstrala en la línea de arriba, entre las otras dos. En `drawHud`:
[source,lua]
----
overlayPrint(1, 1, "SCORE " .. score)
overlayPrint(25, 1, "BEST " .. best)
overlayPrint(50, 1, "LIVES " .. lives)
----
Y dale a `endGame` el trabajo de darse cuenta de un récord:
[source,lua]
----
if score > best then
best = score
end
----
Ejecútalo. El título dice `BEST 0`. Pulsa `1`, dispara a algo, pierde, y la
pantalla de fin de partida muestra tu puntuación como la mejor. Vuelve a pulsar
`1` y la mejor se queda mientras la puntuación vuelve a empezar. Ahora sal,
vuelve a ejecutarlo y mira el título: `BEST 0`. El récord ha vivido exactamente
lo mismo que el programa. Esa es la segunda mitad de esta lección.
Ya que estás mirando las vidas, fíjate en que la lección nueve escribió la
comprobación como `lives <= 0`, no `lives == 0`, y déjala así. Hoy nada puede
quitar dos vidas a la vez, así que las dos solo pueden coincidir. El día que
añadas una roca que cueste dos, `== 0` se salta el cero y el jugador sigue
jugando con menos una vida, mientras que `<= 0` sigue funcionando. Pide lo que
quieres decir.
[#_starting_again]
=== Volver a empezar
Ya has borrado el `startGame()` del final del archivo. En la lección diez la
primera partida la empezaba esa línea y todas las siguientes se empezaban
pulsando `1`, que son dos formas de entrar. Ahora hay una. Cada partida, la
primera incluida, empieza cuando alguien pulsa `1` y se ejecuta `startGame`,
así que la primera partida y la quinta empiezan exactamente de la misma forma.
`startGame` tiene que volver a poner en su sitio todo lo que cambia una
partida:
[source,lua]
----
function startGame()
shipX = (screenWidth - shipWidth) / 2
score = 0
lives = START_LIVES
state = PLAYING
deathChannel = -1
shots = {}
for i = 1, ROCK_COUNT do
rocks[i] = {}
newRock(rocks[i])
end
musicSetVolume(50)
musicPlay(theme, -1)
end
----
Igual de importante es saber qué deja en paz, y por qué. `best` sobrevive a
todas las partidas; ese es todo su trabajo. Las estrellas son decorado. Y
`goLeft` y `goRight` no son del juego en absoluto. Son del teclado, y solo el
teclado debería cambiarlas.
Esa última es un fallo que acabas de arreglar sin darte cuenta. El callback de
entrada de la lección diez empezaba con `if over then`, y mientras la partida
estaba terminada tiraba todas las teclas salvo `1`. Pulsa la flecha derecha
mientras se ve GAME OVER, mantenla pulsada y pulsa `1`. La nave nueva se queda
quieta: la pulsación de la flecha derecha se tiró, todavía no la has soltado, y
por eso `goRight` es `false` mientras la tecla está claramente pulsada. La nave
no se mueve hasta que sueltas y vuelves a pulsar. El callback nuevo apunta las
flechas en todos los estados, así que la tecla mantenida sigue mantenida cuando
empieza la partida.
Los fallos al volver a empezar son los más comunes de cualquier juego, y al
jugar todos se notan igual: la segunda partida está sutilmente mal y la primera
estaba bien. Cuando pase eso, lee `startGame` y hazte dos preguntas. ¿Qué
cambió la partida anterior que esta no vuelve a poner en su sitio? ¿Y qué está
poniendo esta en su sitio que nunca le correspondió tocar al juego?
[#_a_number_that_outlives_the_program]
=== Un número que sobrevive al programa
Todo lo que ha guardado tu juego hasta ahora -- `score`, `lives`, la lista de
rocas -- vive en memoria, y la memoria desaparece en el instante en que el
programa se para.
Singe le da a cada juego una partida guardada propia: un conjunto de nombres
con valores debajo, que se escribe en el disco por ti. Es otra parte del kit de
juego de la lección once, así que es el mismo tipo de cosa que `collideRects`:
llamadas sencillas, nada que cargar ni liberar.
[source,lua]
----
saveSet("highScore", 4200)
----
`saveSet` recibe un nombre -- la *clave* -- y un valor. La clave la eliges tú y
no cambia nunca; el valor es lo que quieras recordar. Caben números, cadenas,
`true` y `false`, y tablas enteras de esas cosas. Lo que solo tiene sentido
mientras el programa se ejecuta, no. Un handle de sprite no es más que un
número, así que se guarda, y mañana es un número que no significa nada. Una
función no se puede escribir en ningún sitio, y si intentas guardar una el
juego se para con un error en lugar de dejar que lo descubras la semana que
viene.
Leerlo de vuelta es la otra mitad. Cambia la línea que añadiste para `best`:
[source,lua]
----
local best = saveGet("highScore", 0)
----
Se ejecuta una vez, cuando arranca el juego. El segundo argumento es el *valor
por defecto*: qué responder cuando nunca se ha guardado nada bajo esa clave.
Enseguida hablamos más de eso, porque es todo el error de esta lección.
Y haz que `endGame` apunte el récord cuando se bata:
[source,lua]
----
function endGame()
deathChannel = -1
state = OVER
musicStop(theme, 1500)
if score > best then
best = score
saveSet("highScore", best)
saveFlush()
end
end
----
No tienes que pedirle a Singe que escriba el archivo. Lo escribe una vez al
final de cualquier fotograma en el que haya cambiado algo, da igual cuántas
claves hayas fijado, y otra vez cuando el juego se cierra. `saveFlush`, en
cambio, lo escribe en el acto: un récord nuevo merece una escritura del archivo
para él solo, porque a una recreativa la apagan desde el enchufe y un fotograma
es mucho tiempo.
Ejecútalo, marca una puntuación, sal y vuelve a ejecutarlo. El título se
acuerda.
La sección Partida guardada de la referencia enumera el resto de la familia.
`saveDelete` olvida una clave, `saveClear` lo olvida todo -- eso es el «borrar
récords» de un menú de servicio -- y `saveGetAll` te da una copia de todo, que
es útil cuando quieres imprimir la partida guardada entera mientras persigues
algo.
[#_where_the_file_actually_goes]
=== Adónde va de verdad el archivo
No va junto a tu script, y no es casualidad.
Un juego terminado puede vivir en un disco, en una carpeta compartida de la red
o dentro de un único archivo empaquetado (lección treinta), y en ninguno de
esos sitios se puede escribir. Un juego que guarda junto a su propio script
funciona perfectamente en el ordenador en el que lo escribiste y falla en la
mitad de los ordenadores en los que se instala. Singe 2 lo hacía así, y estaba
mal.
Así que todo lo que Singe escribe para un juego -- la partida guardada, las
capturas de pantalla, su registro -- va a un solo sitio, y `singeGetDataPath`
te dice dónde:
[source,lua]
----
debugPrint(singeGetDataPath())
----
Ponlo un momento justo debajo de la línea `dofile` y ejecútalo. Dice
`data/rocks/`: la carpeta `data` que apareció en tu carpeta de trabajo la
primera vez que ejecutaste algo, en la lección uno, y dentro una carpeta que se
llama como la carpeta de tu juego. Ahí dentro encontrarás `save.json`. Ábrelo en tu editor de
texto: es texto plano, y tu récord está ahí bajo el nombre que le diste.
Esa es también la respuesta siempre que necesites un archivo propio. Construye
el nombre a partir de `singeGetDataPath()` y acabará en un sitio donde se puede
escribir; escribe en un nombre inventado por ti y tarde o temprano no será así.
[#_the_first_time_there_is_nothing]
=== La primera vez no hay nada
La primerísima vez que alguien ejecuta tu juego, nunca se ha guardado ningún
récord. `saveGet` no tiene nada que devolver, así que devuelve el valor por
defecto que le diste:
[source,lua]
----
local best = saveGet("highScore", 0)
----
No hay nada guardado, así que `best` vale `0`, la pantalla de título dice
`BEST 0`, y la primera partida que juegue alguien marca un récord. Eso es
exactamente lo que quieres, y cuesta un argumento más.
Quita el `0` y `saveGet` no tiene ningún valor por defecto al que recurrir, así
que responde `nil` -- el valor que usa Lua para «aquí no hay nada», que
conociste en la lección tres. `nil` no es cero. No puedes sumarle nada, no
puedes compararlo con un número y no puedes pegarlo a una cadena. Falla en el
primer fotograma, siempre, en cada ordenador donde no se haya jugado al juego
-- lo que incluye todos los ordenadores en los que tus jugadores están a punto
de instalarlo, y no el que usas para probar.
Dale a cada `saveGet` un valor por defecto. Es la costumbre más barata de este
libro.
[#_what_just_happened_8]
=== Qué acaba de pasar
[source,lua]
----
local TITLE = "title"
----
Una constante: un nombre al que das valor una vez y no cambias nunca, en
mayúsculas para que veas de un vistazo que lo es, como en la lección siete. Lua
no lo impone -- nada te impide asignar algo a `TITLE` -- así que las mayúsculas
son un mensaje para la siguiente persona que lo lea, que eres tú dentro de dos
semanas.
[source,lua]
----
if state == PLAYING then
----
La pregunta que decide si el mundo se mueve. Cuando más adelante añadas un
quinto estado -- una pausa, o una ronda de bonificación -- lo primero que hay
que preguntarse es si el mundo se mueve en él, y aquí es donde va la respuesta.
[source,lua]
----
elseif what == SWITCH_START1 and (state == TITLE or state == OVER) then
----
Sin los paréntesis, Lua uniría primero el `and` y lo leería como «la tecla es
`1` y el estado es el título, o el estado es el de fin de partida». Entonces
cualquier tecla que no hubieran atrapado las ramas de arriba empezaría una
partida desde la pantalla de fin de partida, disparo incluido, y un jugador que
siguiera aporreando el botón de disparo cuando le da la última roca se vería
metido en una partida nueva en el momento en que apareciera GAME OVER. Los
paréntesis hacen que el `or` vaya primero. Cuando mezcles `and` y `or`, pon los
paréntesis aunque creas que sabes el orden. Así la siguiente persona que lo lea
no tendrá que saberlo.
[source,lua]
----
saveSet("highScore", best)
saveFlush()
----
`saveSet` mete el número en la partida guardada; `saveFlush` pone la partida
guardada en el disco ahora en lugar de al final del fotograma. Los dos están
dentro del `if`, así que el archivo solo se escribe cuando de verdad se bate el
récord, que es unas pocas veces por sesión y no sesenta veces por segundo.
[source,lua]
----
local best = saveGet("highScore", 0)
----
Se lee una vez, al arrancar, en una variable normal. El resto del juego lee
`best` en lugar de volver a llamar a `saveGet`, porque la partida guardada es
un archivo y una variable es una variable. Escribe en las dos cuando se bata el
récord.
[#_try_it_12]
=== Pruébalo
. *Vuelve al título.* Una máquina recreativa no se queda en GAME OVER para
siempre. Busca `timerAfter` en la sección Temporizador de la referencia, otra
parte del kit de juego, y úsalo en `endGame` para devolver el estado a
`TITLE` cinco segundos después de que termine la partida. Luego pulsa `1`
enseguida al terminar una partida y asegúrate de que el temporizador no
devuelve tu partida nueva al título a los tres segundos.
. *Muestra las vidas como naves.* Dibuja `shipSprite` una vez por vida en la
esquina superior derecha con `spriteDraw` en lugar de imprimir el número. Un
bucle `for` de la lección cuatro, y `shipWidth` te dice a qué distancia
ponerlas.
. *Guarda más que un número.* Añade `saveSet("gamesPlayed", saveGet("gamesPlayed",
0) + 1)` a `startGame`, e imprímelo en la pantalla de título. Sal, vuelve a
ejecutarlo y mira cómo lo recuerda.
. *Encuentra el archivo y rómpelo.* Abre `data/rocks/save.json` en tu editor,
cambia el récord por algo absurdo y ejecuta el juego. Luego borra el archivo
entero y vuelve a ejecutarlo. Las dos cosas les van a pasar a tus jugadores.
. *Bórralo desde dentro del juego.* Haz que una tecla que nunca pulsarías sin
querer llame a `saveClear()`, y mira después la pantalla de título. Cuidado:
`best` sigue en memoria, y `saveClear` no lo toca. Averigua qué más tiene que
pasar.
[#_break_it_on_purpose_11]
=== Rómpelo a propósito
Quita el valor por defecto, para que la línea quede así:
[source,lua]
----
local best = saveGet("highScore")
----
Si ya has marcado un récord, no pasa nada en absoluto, y esa es la trampa: la
partida guardada tiene la clave, así que `saveGet` la devuelve. Borra primero
`data/rocks/save.json` y luego ejecútalo. La pantalla de título no aparece
nunca, y te sale esto:
----
Error executing function 'onOverlayUpdate': rocks/rocks.singe:101: attempt to concatenate a nil value (upvalue 'best')
stack traceback:
rocks/rocks.singe:101: in function 'drawTitle'
rocks/rocks.singe:165: in function 'onOverlayUpdate'
----
Léelo como te enseñó la lección ocho. El archivo y la línea te llevan a
`overlayPrint(25, 9, "BEST " .. best)` en `drawTitle`, y la queja dice que algo
que era `nil` se ha pegado a una cadena. `..` une dos cadenas, y `nil` no es
una. Si tus líneas están colocadas de otra forma, tus números también serán
otros.
La línea que nombra no es donde está el error. La línea 101 es donde el juego
por fin ha tropezado con él; el error se cometió cerca del principio del
archivo, donde se pidió `best` y no había nada. Esa distancia entre dónde se
crea un valor malo y dónde se nota es lo más difícil de `nil`, y la razón de
que un valor por defecto cueste un argumento y te ahorre una tarde.
Vuelve a poner el `0`.
[#_what_you_learned_12]
=== Lo que has aprendido
* Un juego está exactamente en un estado cada vez, y los indicadores de
verdadero o falso que intentan decir cuál crían combinaciones que no
significan nada.
* Guarda el estado en una variable, con sus valores posibles nombrados una vez
arriba del todo del archivo.
* `onOverlayUpdate` decide qué se mueve y qué se dice, preguntando al estado.
* Parar un juego es cuestión de no llamar a lo que lo mueve.
* Empieza cada partida, la primera incluida, a través de una sola función, y
haz que esa función vuelva a poner en su sitio todo lo que cambia una
partida -- y nada más.
* Las teclas son del teclado. Apúntalas en todos los estados.
* `saveSet` guarda un valor bajo una clave, y `saveGet` lo vuelve a leer en una
ejecución posterior.
* `saveFlush` escribe el archivo ya, para las cosas que te daría rabia perder.
* La partida guardada va en la carpeta de datos del propio juego, que nombra
`singeGetDataPath`. Nunca escribas junto a tu script.
* `saveGet` sin valor por defecto responde `nil` cuando no se ha guardado nada,
y `nil` rompe el primer fotograma en un ordenador que nunca ha ejecutado tu
juego.
* Cuando mezcles `and` y `or`, usa paréntesis.
[#_next_time_12]
=== La próxima vez
Tu juego ya es un juego, y es una carpeta que solo tú sabes arrancar. La
lección trece le da un `games.dat`, para que el propio menú del motor lo ponga
en su lista con un título y una imagen, y un jugador que nunca ha oído hablar
de ti pueda arrancarlo sin escribir nada.