865 lines
29 KiB
Text
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.
|