singe/docs/es/lessons/07-a-game.adoc

865 lines
29 KiB
Text

[#_lesson_7_a_game]
== Lección 7: Un juego
image::learn/07-a-game.png[La lección terminada, 480]
Hace seis lecciones pusiste una palabra en una pantalla negra. Desde entonces
has aprendido todas las ideas de las que está hecho un juego pequeño: variables,
decisiones, bucles, funciones propias y listas de cosas. Esta lección no enseña
ninguna idea nueva. Gasta las que ya tienes.
Vas a construir un juego llamado Dodge. Caen bloques del cielo, tú deslizas una
barra por la parte de abajo para apartarte de su camino, y cada bloque que no te
da es un punto. Si te dan tres, se acabó. Lo construirás en seis etapas, y todas
funcionan: nunca tendrás que escribir cien líneas y cruzar los dedos.
[#_make_the_folder]
=== Crea la carpeta
Crea una carpeta llamada `dodge`, y dentro un archivo llamado `dodge.singe`.
Ejecútalo como llevas ejecutándolo todo desde la lección uno:
----
Singe -R dodge
----
Déjalo funcionando en una terminal durante toda la lección. Cada vez que
guardes, el juego se reinicia con tu cambio dentro.
[#_stage_one_something_you_can_steer]
=== Primera etapa: algo que puedes manejar
Escribe esto, guárdalo y manéjalo con las teclas de flecha izquierda y derecha.
[source,lua]
----
dofile("Singe/Framework.singe")
local PLAYER_WIDTH = 40
local PLAYER_HEIGHT = 8
local PLAYER_SPEED = 4
local PLAYER_MARGIN = 4
local screenWidth = overlayGetWidth()
local screenHeight = overlayGetHeight()
local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN
local playerX = (screenWidth - PLAYER_WIDTH) / 2
local goingLeft = false
local goingRight = false
function onInputPressed(what)
if what == SWITCH_LEFT then
goingLeft = true
elseif what == SWITCH_RIGHT then
goingRight = true
end
end
function onInputReleased(what)
if what == SWITCH_LEFT then
goingLeft = false
elseif what == SWITCH_RIGHT then
goingRight = false
end
end
function onOverlayUpdate()
if goingLeft then
playerX = playerX - PLAYER_SPEED
end
if goingRight then
playerX = playerX + PLAYER_SPEED
end
if playerX < 0 then
playerX = 0
elseif playerX > screenWidth - PLAYER_WIDTH then
playerX = screenWidth - PLAYER_WIDTH
end
overlayClear()
colorForeground(80, 255, 160)
overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1)
return OVERLAY_UPDATED
end
----
Cincuenta líneas, y casi nada es nuevo. Hay dos cosas que sí lo son, así que
empieza por la primera línea.
[source,lua]
----
dofile("Singe/Framework.singe")
----
`dofile` es de Lua y no de Singe, y significa «ve y ejecuta ese archivo, y luego
sigue aquí». El archivo que ejecuta es `Singe/Framework.singe`, uno de los
archivos que el motor desempaquetó en tu carpeta de trabajo la primera vez que
ejecutaste algo, allá en la lección uno. Ábrelo en tu editor de texto y míralo:
es Lua normal, del mismo tipo que lo que llevas escribiendo desde la lección
dos, y no tiene nada de mágico.
Ejecutarlo le da a tu script un conjunto de nombres que luego puede usar. Los
que usa este libro son estos:
* `DIR`, que guarda la carpeta en la que está tu propio script. La lección nueve
lo necesita en cuanto un juego carga una imagen, y es la verdadera razón de
que esta línea esté aquí.
* `SCANCODE` y `MODIFIER`, nombres para cada tecla de un teclado, para un juego
que lee lo que se escribe en lugar de un joystick.
* De `GAMEPAD_0` a `GAMEPAD_3` y de `MOUSE_0` a `MOUSE_3`, para una recreativa
que tiene enchufado más de uno de algo.
* Unas cuantas funciones de ayuda cuyos nombres empiezan por `util`, y las seis
llamadas que envían una puntuación a una tabla de récords, que es la lección
veintiocho.
* Otra forma completamente distinta de escribir un juego, que es la lección
quince.
Hay una cosa que *no* te da, y la menciono porque es lo natural suponerlo.
`SWITCH_LEFT`, y cualquier otro nombre que empiece por `SWITCH_`, viene del
propio motor, antes de que se ejecute ningún script. Por eso las lecciones de la
tres a la seis podían leer las teclas de flecha sin cargar nada. El framework
añade cosas a lo que ya está ahí; no es él quien lo pone.
Este juego en concreto no usa nada del framework. La línea está aquí porque
todos los juegos desde la lección nueve hasta el final del libro sí lo
necesitan, y porque es como empiezan casi todos los juegos de Singe de verdad.
Se lee una vez, al arrancar, y luego te olvidas de él.
La segunda cosa nueva es la forma de tratar las teclas de flecha, porque es lo
primero que los principiantes hacen mal.
`onInputPressed` no mueve al jugador. Pone `goingLeft` a `true`, y
`onInputReleased` lo vuelve a poner a `false`. El movimiento ocurre en
`onOverlayUpdate`, que pregunta «¿está el jugador pulsando la izquierda?»
sesenta veces por segundo. Si movieras al jugador dentro de `onInputPressed`,
tendrías un paso por pulsación: Singe envía una pulsación exactamente una vez,
cuando la tecla baja, y nunca la repite, cosa que la entrada de
`onInputPressed` en la referencia dice claramente. Mantener pulsada la tecla no
haría nada en absoluto.
Así que el callback de pulsación apunta lo que es verdad en el mundo, y la
actualización decide qué hacer con ello. Mantén esa separación y tu código de
entrada tendrá cinco líneas para siempre.
Los cuatro nombres en mayúsculas son variables normales. Escribir `PLAYER_SPEED`
en mayúsculas es un mensaje para ti y para quien lo lea: esta se fija una vez
arriba y no cambia nunca. Nada en Lua lo obliga. Importa porque dentro de un
momento habrá diez, y cuando el juego sea demasiado difícil querrás un sitio
obvio al que ir a bajar un número.
`screenWidth` y `screenHeight` vienen del motor en lugar de escribirse a mano,
así que el juego se adapta al tamaño que tenga el overlay. `playerY` se calcula
una vez a partir de la altura: la barra está a `PLAYER_MARGIN` píxeles del borde
de abajo y nunca sube ni baja, así que no hay razón para volver a calcularlo en
cada fotograma.
El `- 1` de la línea de `overlayBox` no es una errata. `overlayBox` dibuja
incluyendo las dos esquinas que le das, así que una caja de `playerX` a
`playerX + 40` tiene cuarenta y un píxeles de ancho. Restar uno hace que la
barra que ves tenga exactamente cuarenta de ancho, lo que importará en la cuarta
etapa, cuando algo tenga que decidir si te ha tocado.
[#_stage_two_something_to_dodge]
=== Segunda etapa: algo que esquivar
Un bloque, cayendo. Añade dos constantes a la lista de arriba:
[source,lua]
----
local BLOCK_SIZE = 12
local BLOCK_SPEED = 1.5
----
Dos variables más debajo de `goingRight`:
[source,lua]
----
local blockX = math.random(0, screenWidth - BLOCK_SIZE)
local blockY = -BLOCK_SIZE
----
En `onOverlayUpdate`, justo encima de la línea `overlayClear()`, haz que caiga:
[source,lua]
----
blockY = blockY + BLOCK_SPEED
if blockY > screenHeight then
blockX = math.random(0, screenWidth - BLOCK_SIZE)
blockY = -BLOCK_SIZE
end
----
Y debajo de la línea que dibuja al jugador, dibújalo:
[source,lua]
----
colorForeground(255, 90, 90)
overlayBox(blockX, blockY, blockX + BLOCK_SIZE - 1, blockY + BLOCK_SIZE - 1)
----
Guarda. Un cuadrado rojo cae, llega abajo y vuelve a aparecer arriba en otro
sitio.
Empieza en `-BLOCK_SIZE`, que está por encima del borde superior de la
pantalla. Una posición negativa no tiene nada de malo: el motor dibuja la parte
de la caja que está en el overlay y tira sin decir nada la parte que no. El
bloque entra deslizándose en lugar de aparecer de golpe.
`BLOCK_SPEED` es `1.5`, no un número entero, así que `blockY` pasa la mayor
parte de su vida valiendo algo como `83.5`. Eso también está bien. Al dibujar se
corta la fracción, y conservarla en la variable es lo que permite que el bloque
se mueva más despacio que un píxel por fotograma.
[#_stage_three_a_sky_full_of_them]
=== Tercera etapa: un cielo lleno de ellos
Un bloque no es un juego. En la lección seis aprendiste a guardar muchos de algo
en una lista, y esto es para lo que servía.
Borra `blockX` y `blockY`, y pon esto en su lugar:
[source,lua]
----
local blocks = {}
local spawnTimer = 0
----
Añade una constante más arriba:
[source,lua]
----
local SPAWN_FRAMES = 40
----
Ahora una función que crea un bloque y lo mete en la lista. Ponla encima de
`onInputPressed`:
[source,lua]
----
function spawnBlock()
local block = {}
block.x = math.random(0, screenWidth - BLOCK_SIZE)
block.y = -BLOCK_SIZE
table.insert(blocks, block)
end
----
Sustituye el código de caída de la segunda etapa por esto:
[source,lua]
----
spawnTimer = spawnTimer - 1
if spawnTimer <= 0 then
spawnBlock()
spawnTimer = SPAWN_FRAMES
end
for i = #blocks, 1, -1 do
local block = blocks[i]
block.y = block.y + BLOCK_SPEED
if block.y > screenHeight then
table.remove(blocks, i)
end
end
----
Y sustituye la única línea que dibujaba el bloque por un bucle que los recorre
todos:
[source,lua]
----
for i, block in ipairs(blocks) do
overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1)
end
----
Guarda. Llueven bloques.
Un bloque es un registro: una tablita con una `x` y una `y` dentro, que es como
evitas que dos números que van juntos se separen. `blocks` es una lista de esos
registros, que es la forma que tiene el mundo de casi cualquier juego.
`spawnTimer` cuenta hacia atrás de uno en uno en cada fotograma, y cuando llega
a cero nace un bloque y el temporizador se vuelve a cargar. Cuarenta fotogramas
son unos dos tercios de segundo. En este juego no hay reloj y no hace falta:
`onOverlayUpdate` es el latido, y contar latidos es una forma perfectamente
buena de medir el tiempo.
El bucle de caída va *hacia atrás* -- de `#blocks` a `1`, de uno en uno --
porque va quitando cosas. Es la regla de la lección seis, y esta es la primera
vez que tienes una razón de verdad para cumplirla. Quita el elemento tres
recorriendo hacia delante y todo se corre un puesto hacia abajo, así que el
bloque que era el elemento cuatro ahora es el tres y el bucle no lo mira nunca.
Yendo hacia atrás, todo lo que se mueve ya se ha tratado.
El bucle de dibujo usa `ipairs`, porque no quita nada y solo quiere cada bloque
por turno.
[#_stage_four_getting_hit]
=== Cuarta etapa: recibir un golpe
Los bloques te atraviesan. Es hora de darse cuenta.
El motor tiene una función justo para esta pregunta. Cambia la comprobación de
dentro del bucle de caída de esto:
[source,lua]
----
if block.y > screenHeight then
table.remove(blocks, i)
end
----
a esto:
[source,lua]
----
if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) or block.y > screenHeight then
table.remove(blocks, i)
end
----
Guarda, y lleva la barra contra un bloque que cae. Desaparece.
`collideRects` recibe dos rectángulos y responde `true` si se solapan. Cada
rectángulo son cuatro números: una esquina, y luego un ancho y un alto -- *no*
dos esquinas, que es la otra forma razonable de describir un rectángulo y la que
usa `overlayBox`. La entrada de `collideRects` en la referencia da el orden, y vale
la pena mirarla ahora, porque poner los ocho argumentos en el orden equivocado
es el tipo de equivocación que no produce ningún error, solo un juego en el que
nada choca nunca con nada.
Podrías escribirla tú. Son cuatro comparaciones, y de verdad que no es difícil.
Usa la del motor de todos modos, por tres razones. Es correcta, también en los
casos difíciles: un rectángulo sin ancho, dos rectángulos que solo se tocan por
un borde. Dice lo que significa, así que la línea se lee como «¿se han solapado
estos dos?» en lugar de cuatro comparaciones unidas por `and` que tienes que
descifrar cada vez que vuelves a ella. Y cuando llegues a la lección once, donde las cosas chocan
constantemente, ya conocerás la familia: `collidePointRect` para un clic del
ratón, `collideCircles` para dos cosas redondas y `collideSegments` para un
disparo contra una pared. La referencia las enumera juntas.
Lo único que el motor no puede hacer por ti es decidir qué *significa* un
rectángulo. Aquí el rectángulo del bloque es el bloque entero. En un juego de
verdad a menudo quieres que el rectángulo de impacto sea un poco más pequeño que
la imagen, para que librarte por poco se sienta como librarte por poco. Eso es
una decisión de diseño, y la lección once es donde la tomas.
[#_stage_five_score_and_lives]
=== Quinta etapa: puntuación y vidas
Que te den debería costar algo, y sobrevivir debería valer algo.
Dos constantes más arriba:
[source,lua]
----
local BLOCK_FASTER = 0.05
local START_LIVES = 3
----
Tres variables más, junto a `spawnTimer`:
[source,lua]
----
local blockSpeed = BLOCK_SPEED
local score = 0
local lives = START_LIVES
----
`BLOCK_SPEED` es ahora la velocidad a la que *empieza* el juego, y `blockSpeed`
es la velocidad a la que va ahora mismo. Cambia la línea de caída para que use
la variable:
[source,lua]
----
block.y = block.y + blockSpeed
----
Luego separa los dos casos, porque ahora hacen cosas distintas:
[source,lua]
----
if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then
table.remove(blocks, i)
lives = lives - 1
elseif block.y > screenHeight then
table.remove(blocks, i)
score = score + 1
blockSpeed = blockSpeed + BLOCK_FASTER
end
----
Y muestra los números. Pon esto justo antes del `return`:
[source,lua]
----
overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives)
----
Guarda, y juega un minuto. Se va poniendo más difícil, y ese es todo el truco:
cada bloque que esquivas hace que los siguientes caigan una vigésima de píxel
por fotograma más deprisa. A los veinte puntos ya van claramente más rápido. No
has tenido que escribir ni una palabra sobre niveles de dificultad.
También verás `LIVES` llegar a `-4`, porque todavía nada detiene el juego. Esa
es la siguiente etapa, y es la que más importa.
[#_stage_six_game_over_and_going_again]
=== Sexta etapa: fin de partida y volver a jugar
Aquí está la trampa. La forma obvia de añadir una pantalla de fin de partida es
una variable llamada `gameOver`, que se pone a `true` cuando se acaban las
vidas. Luego quieres una pantalla de título antes de la primera partida, así que
añades `started`. Luego quieres una tecla de pausa, así que añades `paused`.
Ahora tienes tres variables de verdadero o falso, ocho combinaciones entre
ellas, y cinco de esas combinaciones son disparates que tu código tiene que
tener cuidado de no producir nunca. Todos los principiantes escriben este juego
al menos una vez, y es donde los juegos pequeños van a morir.
Haz esto en su lugar. Una sola variable guarda *lo que está haciendo el juego*,
como una palabra:
[source,lua]
----
local state = "waiting"
----
Vale `"waiting"` antes de la primera partida, `"playing"` durante una y
`"over"` cuando se acaban las vidas. Tres estados, una variable y ninguna
combinación imposible. Las cadenas no tienen nada de mágico; son palabras que
has elegido tú, y tienes que escribirlas igual todas las veces.
Ahora la función que empieza una partida. Cada variable que pertenece a una
partida vuelve aquí a su valor inicial, en un solo sitio:
[source,lua]
----
function startGame()
playerX = (screenWidth - PLAYER_WIDTH) / 2
blocks = {}
blockSpeed = BLOCK_SPEED
spawnTimer = 0
score = 0
lives = START_LIVES
state = "playing"
end
----
Perder la última vida la termina. Dentro de la rama de la colisión, debajo de
`lives = lives - 1`:
[source,lua]
----
if lives == 0 then
state = "over"
end
----
La barra espaciadora empieza una partida, pero solo cuando no hay una ya en
marcha. Añade una rama a `onInputPressed`:
[source,lua]
----
elseif what == SWITCH_BUTTON1 and state ~= "playing" then
startGame()
----
`~=` es «no es igual a». `state ~= "playing"` es verdadero mientras se espera y
verdadero cuando se ha acabado, que son justo los dos momentos en los que la
barra espaciadora debe empezar una partida. `SWITCH_BUTTON1` es la barra
espaciadora por defecto, y también el botón A de un mando.
Dos funciones pequeñas ponen las palabras en pantalla. `printCentered` hace las
cuentas una sola vez para que las pantallas de título y de fin de partida no las
hagan mal cada una por su lado:
[source,lua]
----
function drawOverText()
printCentered(7, "GAME OVER")
printCentered(9, "PRESS SPACE TO PLAY AGAIN")
end
function drawWaitingText()
printCentered(6, "DODGE THE BLOCKS")
printCentered(8, "ARROW KEYS TO MOVE")
printCentered(10, "PRESS SPACE TO START")
end
function printCentered(row, text)
overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text)
end
----
con una constante más arriba:
[source,lua]
----
local TEXT_COLUMNS = overlayGetWidth() // overlayGetFontWidth()
----
Por último, la parte importante. Saca de `onOverlayUpdate` todo lo que dibuja y
llévalo a una función llamada `drawGame`, lleva todo lo que mueve a una función
llamada `updatePlaying` -- las dos están en el listado completo de abajo -- y
deja `onOverlayUpdate` así:
[source,lua]
----
function onOverlayUpdate()
if state == "playing" then
updatePlaying()
end
overlayClear()
drawGame()
if state == "waiting" then
drawWaitingText()
elseif state == "over" then
drawOverText()
end
return OVERLAY_UPDATED
end
----
Guarda. Tienes un juego: una pantalla de título, una partida, un fin de partida
y una forma de volver a empezar.
Vuelve a leer esas trece líneas, porque son la forma de todos los juegos que vas
a escribir. El mundo solo se mueve mientras el estado es `"playing"`, así que la
pantalla de fin de partida se congela con los últimos bloques colgando justo
donde estaban. El mundo se dibuja en cada fotograma sea cual sea el estado,
porque la imagen es la misma en los tres; el estado solo decide qué palabras van
encima. Y hay exactamente un sitio que toma esa decisión.
Eso es lo que has comprado con la variable `state`. Cuando añadas una pausa,
añadirás una rama a ese `if` y una línea a `onInputPressed`, y nada más del
juego tendrá que saber nada de ella.
[#_the_whole_thing_6]
=== Todo junto
Aquí lo tienes, terminado. Si el tuyo no coincide, vale la pena encontrar la
diferencia.
[source,lua]
----
dofile("Singe/Framework.singe")
local PLAYER_WIDTH = 40
local PLAYER_HEIGHT = 8
local PLAYER_SPEED = 4
local PLAYER_MARGIN = 4
local BLOCK_SIZE = 12
local BLOCK_SPEED = 1.5
local BLOCK_FASTER = 0.05
local SPAWN_FRAMES = 40
local START_LIVES = 3
local TEXT_COLUMNS = overlayGetWidth() // overlayGetFontWidth()
local screenWidth = overlayGetWidth()
local screenHeight = overlayGetHeight()
local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN
local playerX = (screenWidth - PLAYER_WIDTH) / 2
local goingLeft = false
local goingRight = false
local blocks = {}
local blockSpeed = BLOCK_SPEED
local spawnTimer = 0
local score = 0
local lives = START_LIVES
local state = "waiting"
function drawGame()
colorForeground(80, 255, 160)
overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1)
colorForeground(255, 90, 90)
for i, block in ipairs(blocks) do
overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1)
end
overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives)
end
function drawOverText()
printCentered(7, "GAME OVER")
printCentered(9, "PRESS SPACE TO PLAY AGAIN")
end
function drawWaitingText()
printCentered(6, "DODGE THE BLOCKS")
printCentered(8, "ARROW KEYS TO MOVE")
printCentered(10, "PRESS SPACE TO START")
end
function printCentered(row, text)
overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text)
end
function spawnBlock()
local block = {}
block.x = math.random(0, screenWidth - BLOCK_SIZE)
block.y = -BLOCK_SIZE
table.insert(blocks, block)
end
function startGame()
playerX = (screenWidth - PLAYER_WIDTH) / 2
blocks = {}
blockSpeed = BLOCK_SPEED
spawnTimer = 0
score = 0
lives = START_LIVES
state = "playing"
end
function updatePlaying()
if goingLeft then
playerX = playerX - PLAYER_SPEED
end
if goingRight then
playerX = playerX + PLAYER_SPEED
end
if playerX < 0 then
playerX = 0
elseif playerX > screenWidth - PLAYER_WIDTH then
playerX = screenWidth - PLAYER_WIDTH
end
spawnTimer = spawnTimer - 1
if spawnTimer <= 0 then
spawnBlock()
spawnTimer = SPAWN_FRAMES
end
for i = #blocks, 1, -1 do
local block = blocks[i]
block.y = block.y + blockSpeed
if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then
table.remove(blocks, i)
lives = lives - 1
if lives == 0 then
state = "over"
end
elseif block.y > screenHeight then
table.remove(blocks, i)
score = score + 1
blockSpeed = blockSpeed + BLOCK_FASTER
end
end
end
function onInputPressed(what)
if what == SWITCH_LEFT then
goingLeft = true
elseif what == SWITCH_RIGHT then
goingRight = true
elseif what == SWITCH_BUTTON1 and state ~= "playing" then
startGame()
end
end
function onInputReleased(what)
if what == SWITCH_LEFT then
goingLeft = false
elseif what == SWITCH_RIGHT then
goingRight = false
end
end
function onOverlayUpdate()
if state == "playing" then
updatePlaying()
end
overlayClear()
drawGame()
if state == "waiting" then
drawWaitingText()
elseif state == "over" then
drawOverText()
end
return OVERLAY_UPDATED
end
----
Unas ciento veinte líneas de código, y las has escrito todas de unas pocas en
unas pocas.
[#_what_just_happened_4]
=== Qué acaba de pasar
Algunas partes merecen una segunda mirada.
[source,lua]
----
local blocks = {}
local score = 0
function startGame()
score = 0
end
----
Los datos son `local` y las funciones no. Son dos decisiones deliberadas y
opuestas.
Los datos son `local` para que pertenezcan a este archivo y nada de fuera pueda
meter la mano y cambiarlos. Las funciones son globales para que puedan llamarse
unas a otras en cualquier orden: `drawOverText` se define antes que `printCentered`
y la llama de todos modos, porque un nombre global se busca en el momento de la
llamada, y para entonces ya se ha leído todo el archivo. Haz `local` esas
funciones y el archivo tendrá que estar ordenado para que nada se use antes de
aparecer, que es una regla con la que tropezarías durante el resto del libro.
Tres de los nombres de aquí no tienen elección: `onOverlayUpdate` y los dos
callbacks de entrada tienen que ser globales, porque así es como los encuentra
Singe.
Dentro del archivo, el orden es alfabético, con los tres callbacks al final. No
es una regla de Lua. Es para que dentro de seis meses puedas encontrar
`spawnBlock` sin leer nada.
[source,lua]
----
overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text)
----
`+#text+` es la longitud de una cadena, en caracteres. Conociste `+#+` en la
lección seis contando los elementos de una lista, y aquí hace el mismo trabajo.
`"GAME OVER"` tiene nueve caracteres, así que en una pantalla de sesenta
columnas empieza en la columna veinticinco, lo que deja veinticinco celdas de
espacio a su izquierda y veintiséis a su derecha.
`math.floor` tira la fracción, porque no existe la columna veinticinco y media.
`TEXT_COLUMNS` calcula cuántas celdas de carácter caben a lo ancho de la
pantalla. `overlayPrint` cuenta en celdas mientras que todo lo demás cuenta en
píxeles, así que el ancho del overlay se divide entre el ancho de una celda, y
`overlayGetFontWidth` es lo que te lo dice. Dos barras en lugar de una, porque
una fracción de columna no es un sitio donde imprimir. Calcúlalo así y el
centrado sigue siendo correcto con cualquier tamaño de overlay, que es lo cuarto
de *Pruébalo*.
[source,lua]
----
colorForeground(255, 90, 90)
----
`colorForeground` fija un color que se queda fijado. No es un argumento de
`overlayBox`; es un interruptor que accionas, y todo lo que se dibuja después
sale en ese color hasta que lo vuelves a accionar. Por eso `drawGame` pone el
verde, dibuja al jugador, pone el rojo y luego dibuja todos los bloques sin
volver a mencionar el color.
No afecta a `overlayPrint`. La fuente de consola se copia en el overlay tal
cual, en sus propios colores, cosa que la entrada de `overlayPrint` en la
referencia dice con todas las letras. El texto en color necesita una fuente
cargada, y eso es la lección diecinueve.
[#_try_it_7]
=== Pruébalo
. *Hazlo más fácil, y luego injusto.* Cambia `PLAYER_WIDTH` a `80` y juega una
partida. Luego cámbialo a `12`, el mismo tamaño que un bloque. Fíjate en que
no has tenido que tocar ni el dibujo ni la colisión para ninguna de las dos
cosas.
. *Que acelere más deprisa.* Cambia `BLOCK_FASTER` de `0.05` a `0.5` y mira
cuántos puntos consigues. Luego prueba `0`, y pregúntate si el juego sigue
mereciendo la pena.
. *Una vida extra.* Devuélvele una vida al jugador cada diez puntos. Todo lo
que necesitas ya está en `updatePlaying`, en la rama que suma a la
puntuación.
. *Juega en grande.* Ejecútalo con `Singe -R --canvas=1280x720 dodge`. El
juego se coloca bien, texto centrado incluido, porque le preguntó al motor
cuánto medía la pantalla. `TEXT_COLUMNS` también lo preguntó.
. *Ponlo en pausa.* Añade un cuarto estado, `"paused"`. `SWITCH_BUTTON2`
debería cambiar entre `"playing"` y `"paused"`, y nada debería moverse en
pausa. Bien hecho, son tres líneas y no tienes que tocar `updatePlaying` en
absoluto.
[#_break_it_on_purpose_7]
=== Rómpelo a propósito
Ve a `spawnBlock` y borra la línea que da valor a `block.y`, para que quede
así:
[source,lua]
----
function spawnBlock()
local block = {}
block.x = math.random(0, screenWidth - BLOCK_SIZE)
table.insert(blocks, block)
end
----
Guarda, pulsa la barra espaciadora, y Singe se detiene con esto:
----
Error executing function 'onOverlayUpdate': dodge/dodge.singe:97: attempt to perform arithmetic on a nil value (field 'y')
stack traceback:
dodge/dodge.singe:97: in function 'updatePlaying'
dodge/dodge.singe:135: in function 'onOverlayUpdate'
----
La línea 97 es `block.y = block.y + blockSpeed`. La queja es que algo en esa
suma no es un número, y la parte entre paréntesis te dice cuál: `field 'y'`, es
decir, la `y` de dentro de una tabla. No tiene valor, porque nunca se lo diste,
y algo sin valor en Lua es `nil`.
Fíjate en dónde está el error y dónde está la equivocación. El error está en
`updatePlaying`, a cuarenta líneas de `spawnBlock`, donde no hay nada mal en
absoluto. `updatePlaying` es solo el primer trozo de código con la mala suerte
de tocar el destrozo. Esto es lo normal, no la excepción, y seguir ese rastro
hacia atrás es una habilidad.
Vuelve a poner la línea. Luego pasa la página, porque la lección siguiente no
trata de otra cosa.
[#_what_you_learned_7]
=== Lo que has aprendido
* Un juego es un mundo que cambia, un mundo que se dibuja y una decisión sobre
cuál de las dos cosas hacer -- y son tres trozos de código separados.
* Los callbacks de entrada deberían apuntar lo que está pulsado; la
actualización debería decidir qué significa eso.
* Una lista de registros es la forma de casi cualquier mundo de juego.
* Quita cosas de una lista recorriéndola hacia atrás.
* `collideRects` responde si dos rectángulos se solapan, y la versión del motor
es mejor que la tuya porque es correcta, se lee bien y tiene una familia que
volverás a ver.
* Una variable `state` que guarda una palabra gana a tres indicadores de
verdadero o falso, y les sigue ganando a medida que el juego crece.
* Empieza una partida desde una función que lo reinicia todo, para que la
segunda partida sea exactamente igual que la primera.
* Los números que quizá quieras ajustar van en constantes con nombre al
principio del archivo.
* Construye un juego por etapas que funcionen cada una. Un programa que no ha
funcionado nunca es mucho más difícil de arreglar que uno que funcionaba hace
cinco minutos.
[#_next_time_7]
=== La próxima vez
Ya has escrito código suficiente para cometer errores interesantes. La lección
siguiente es la que ningún libro para principiantes se molesta en incluir: qué
dicen de verdad los mensajes de error de Singe, cómo averiguar lo que guarda
realmente una variable en lugar de adivinarlo, y cómo partir un problema por la
mitad hasta que no le quede ningún sitio donde esconderse.