singe/docs/es/lessons/11-hitting-things.adoc

535 lines
21 KiB
Text

[#_lesson_11_hitting_things]
== Lección 11: Chocar con cosas
image::learn/11-hitting-things.png[La lección terminada, 480]
Tu juego de la lección diez funciona. Los disparos rompen rocas, las rocas
cuestan vidas y las explosiones suenan donde tienen que sonar. Pero míralo de
cerca y verás que hace trampa, en las dos direcciones. Una roca que pasa
rozando la punta de un ala te quita una vida. Un disparo que atraviesa el cielo
vacío junto a una roca la rompe igualmente.
En un juego, dos cosas se tocan cuando los números dicen que se tocan. Nadie
mira las imágenes. Esta lección hace que los números estén de acuerdo con lo
que ve el jugador: cambia tu comprobación escrita a mano por la del propio
motor, encoge las cajas que se comprueban para que se ajusten a las cosas que
hay dentro y te da un interruptor que te enseña esas cajas. La imagen de arriba
es el juego con ese interruptor encendido.
[#_where_you_are]
=== Dónde estás
El juego pregunta «¿se tocan estas dos cosas?» exactamente en dos sitios, y los
dos usan `overlapping`, la función que escribiste en la lección nueve. Uno está
en `updateRocks`, donde una roca se encuentra con la nave:
[source,lua]
----
if overlapping(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
El otro está en `updateShots`, donde un disparo se encuentra con una roca:
[source,lua]
----
if not gone and overlapping(shot.x, shot.y, shotWidth, shotHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
Todo lo de esta lección cambia esas dos líneas y añade unas cuantas funciones
pequeñas que las alimentan. Nada más del juego se mueve. El script terminado
entero está en `learn/11-hitting-things.singe`, por si te pierdes.
[#_read_it_once_more]
=== Léela una vez más
La lección nueve ya hizo esta parte: `overlapping` es la colisión de
rectángulos escrita a mano. Léela una vez más antes de tirarla, porque es la
idea entera:
[source,lua]
----
function overlapping(ax, ay, aw, ah, bx, by, bw, bh)
if ax + aw <= bx then
return false
end
if bx + bw <= ax then
return false
end
if ay + ah <= by then
return false
end
if by + bh <= ay then
return false
end
return true
end
----
La pregunta «¿se están tocando estas dos cosas?» es difícil de responder
directamente y fácil de responder al revés. Dos rectángulos *no* se tocan
cuando uno de ellos está entero a la izquierda del otro, o entero a la derecha,
o entero encima, o entero debajo. No hay una quinta forma de fallar. Si no se
cumple ninguna de esas cuatro, se tocan.
Cada `if` es una de las cuatro formas de fallar. `ax + aw` es el borde derecho
del primer rectángulo, así que `ax + aw <= bx` se lee «el primero termina antes
de que empiece el segundo», que es lo que significa «entero a la izquierda» en
números. La misma forma tres veces más, para la derecha, encima y debajo, con
`y` y las alturas en lugar de `x` y las anchuras, y lo único que queda es
`true`.
Esos son los ocho números que necesita cualquier comprobación de colisión: una
esquina y un tamaño para cada una de dos cosas. Guarda la forma en la cabeza,
porque estás a punto de borrarla.
[#_the_engine_already_has_it]
=== El motor ya lo tiene
Conociste `collideRects` en la lección siete, donde atrapaba los bloques que
caían en Dodge. La lección nueve escribió la misma comprobación a mano para que
pudieras verla por dentro. Ya la has visto por dentro. Borra `overlapping` y
cambia las dos líneas de *Dónde estás* para que llamen al motor:
[source,lua]
----
if collideRects(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
[source,lua]
----
if not gone and collideRects(shot.x, shot.y, shotWidth, shotHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
Los mismos ocho números, en el mismo orden: la esquina y el tamaño del primer
rectángulo, y luego los del segundo. Ejecútalo, y el juego funciona exactamente
igual que antes, que es de lo que se trata. La única diferencia está justo en
el borde. Tu `<=` decía que dos rectángulos que se encuentran a lo largo de un
borde no se tocan. `collideRects` dice que sí, y un rectángulo sin anchura o
sin altura no toca nada en absoluto. Esos casos incómodos son donde las
versiones escritas a mano fallan sin avisar, y son una de las razones para usar
la del motor.
`collideRects` es una de una familia de comprobaciones de colisión, y la
familia es una parte de lo que este libro llama el *kit de juego*: un puñado de
funciones sencillas para las cosas que casi todos los juegos tenían que
escribirse ellos mismos. Comprobar si dos cosas se tocan es una. Guardar un
número para la próxima vez es otra, y es la lección doce. Esperar un rato,
llevar un número suavemente de un valor a otro y mostrar cuánto ha tardado cada
fotograma son el resto. La referencia le da a cada parte una sección propia:
Colisión, Partida guardada, Temporizador, Interpolación y Estadísticas.
Las comprobaciones de colisión son las más sencillas de todas: preguntas
sencillas, sin handles que guardar ni nada que preparar o liberar. Y ninguna
mueve nada. `collideRects` te dice que la nave y la roca están en el mismo
sitio; qué hacer al respecto lo decides tú, y todo lo interesante de un juego
ocurre en esa decisión.
[#_the_picture_is_not_the_hit_box]
=== La imagen no es la zona de impacto
La lección diez prometió explicar por qué el rectángulo que rodea una roca
redonda tiene la forma equivocada. El rectángulo que rodea la nave también está
mal, y por la misma razón.
Dibuja en papel el rectángulo de 32 por 24 de la nave y sombrea la nave. Es un
triángulo con dos aletas, así que hay cielo vacío en las dos esquinas de arriba
y entre las aletas. La roca es redonda, así que hay cielo vacío en las cuatro
esquinas de su cuadrado. Una roca que roza la esquina de arriba del rectángulo
de la nave no ha chocado con la nave. Ha pasado volando junto al ala, y un
jugador que pierde una vida por eso dirá que el juego ha hecho trampa -- y
tendrá razón.
Así que la caja que compruebas no es la imagen que dibujas. Es más pequeña, y
está dentro de la imagen. Añade tres números a las constantes de arriba del
todo de tu archivo:
[source,lua]
----
local SHIP_PAD_X = 6
local SHIP_PAD_Y = 5
local ROCK_PAD = 4
----
El margen es lo lejos de cada borde que empieza la cosa de verdad. Seis
píxeles menos por cada lado de la nave y cinco menos por arriba y por abajo
dejan una caja de 20 por 14 en el centro de una imagen de 32 por 24, que es más
o menos donde está el casco. Cuatro menos por cada lado de la roca dejan 16 por
16, y eso es casi todo roca.
Las dos cajas son un poco más pequeñas que las cosas que representan, y es a
propósito. Una caja un poco demasiado pequeña le da al jugador escapadas por
los pelos que parecen habilidad. Una caja un poco demasiado grande le da golpes
que parecen trampa. Los jugadores perdonan lo primero y nunca lo segundo.
En lugar de escribir la aritmética en cada comprobación, escríbela una vez para
cada tipo de cosa:
[source,lua]
----
function rockBox(rock)
return rock.x + ROCK_PAD, rock.y + ROCK_PAD, rockWidth - ROCK_PAD * 2, rockHeight - ROCK_PAD * 2
end
function shipBox()
return shipX + SHIP_PAD_X, shipY + SHIP_PAD_Y, shipWidth - SHIP_PAD_X * 2, shipHeight - SHIP_PAD_Y * 2
end
----
Los tamaños salen de `rockWidth`, `shipWidth` y compañía, las medidas que la
lección nueve tomó de las imágenes, así que sigue sin haber ni un tamaño en
píxeles a pelo en el código del juego. Vuelve a dibujar la nave más grande y la
caja crece con ella.
Cada una de esas funciones devuelve cuatro números a la vez, separados por
comas. Una función puede devolver tantos valores como quiera, y la forma de
recogerlos es una lista de nombres a la izquierda del `=`:
[source,lua]
----
function shipHitsRock(rock)
local sx, sy, sw, sh = shipBox()
local rx, ry, rw, rh = rockBox(rock)
return collideRects(sx, sy, sw, sh, rx, ry, rw, rh)
end
----
Ahora la comprobación de `updateRocks` se lee como la pregunta que hace:
[source,lua]
----
if shipHitsRock(rock) then
----
La aritmética de los márgenes está exactamente en dos sitios, y los números que
usa están arriba del todo del archivo, así que puedes ajustar lo justo que se
siente el juego cambiando un número en lugar de ir buscando por tus
comprobaciones.
Pon las funciones nuevas en su sitio del orden alfabético, como pedía la
lección nueve: `rockBox` después de `onSoundCompleted`, y luego `shipBox` y
`shipHitsRock`.
[#_firing_back]
=== Devolver el fuego
Con eso está hecha la parte de la nave. La otra colisión del juego es con la
que devuelves el fuego: cada disparo contra cada roca, en `updateShots`. Tiene
el mismo problema que tenía la nave, en pequeño. Un disparo es un rectángulo de
4 por 10 que se comprueba contra el cuadrado entero de 24 por 24 de la roca,
así que un disparo que solo pasa por la esquina vacía junto a una roca la rompe
igualmente.
Podrías darle al disparo una caja con márgenes propia. Pero mira antes los
números. Quítale márgenes a un disparo de cuatro píxeles de ancho y no queda
casi nada, y comprobar un rectángulo entero para saber si una astilla de luz ha
tocado algo es más maquinaria de la que merece la pregunta.
[#_a_shot_is_almost_a_point]
=== Un disparo es casi un punto
Comprueba la punta en su lugar. `collidePointRect` pregunta si un punto cae
dentro de un rectángulo -- dos números para el punto, y luego la esquina y el
tamaño del rectángulo:
[source,lua]
----
function shotHitsRock(shot, rock)
local rx, ry, rw, rh = rockBox(rock)
return collidePointRect(shot.x + shotWidth / 2, shot.y, rx, ry, rw, rh)
end
----
`shot.y` es la parte de arriba del disparo y `shot.x + shotWidth / 2` es la
mitad de su anchura, así que el punto que se comprueba es el centro de la punta
del disparo. Ahí es donde un jugador cree que está un disparo. El lado de la
roca en esta pregunta usa el mismo `rockBox` que la nave, así que una roca mide
lo mismo le dé lo que le dé.
Va después de `shipHitsRock`, y la comprobación de `updateShots` queda así:
[source,lua]
----
if not gone and shotHitsRock(shot, rock) then
----
[#_deciding_what_happened]
=== Decidir qué ha pasado
Ninguna de las comprobaciones de colisión hace nada. Responden, y tu código
decide. Aquí están los dos sitios que deciden, con sus preguntas nuevas dentro.
Primero `updateRocks`:
[source,lua]
----
if shipHitsRock(rock) then
local channel = soundPlay(boomClip)
newRock(rock)
lives = lives - 1
if lives <= 0 then
dying = true
deathChannel = channel
if channel < 0 then
endGame()
end
end
end
----
Y el bucle de dentro de `updateShots`:
[source,lua]
----
for _, rock in ipairs(rocks) do
if not gone and shotHitsRock(shot, rock) then
newRock(rock)
score = score + HIT_SCORE
gone = true
soundPlay(boomClip, 0, 45)
end
end
----
Todo lo que hay debajo de las preguntas es exactamente lo que dejó ahí la
lección diez. Una roca que choca con la nave estalla, vuelve arriba y cuesta
una vida. Una roca alcanzada por un disparo estalla, vuelve arriba y suma
puntos. El disparo también desaparece, y el `not gone` impide que un disparo
rompa dos rocas. Cambia cualquiera de esas cosas y cambias el juego, sin tocar
ni una sola comprobación de colisión, que es la razón de que las preguntas y
las respuestas se mantengan separadas.
Ejecútalo. Pasa volando cerca de una roca y deja que te roce una aleta:
conservas la vida. Dispara justo al lado de una roca y el disparo pasa de
largo. Dispara al centro de una y se rompe, igual que siempre.
[#_seeing_the_boxes]
=== Ver las cajas
Las zonas de impacto son invisibles, y eso hace que un error en ellas también
lo sea. Dibújalas mientras trabajas. Una constante más, arriba del todo:
[source,lua]
----
local SHOW_BOXES = false
----
Dos funciones más, que van al principio del todo del orden alfabético:
[source,lua]
----
function drawBox(x, y, width, height)
overlayBox(x, y, x + width - 1, y + height - 1)
end
function drawBoxes()
colorForeground(0, 255, 0)
drawBox(shipBox())
for _, rock in ipairs(rocks) do
drawBox(rockBox(rock))
end
end
----
Y en `onOverlayUpdate`, después de dibujar la nave y antes de que la puntuación
vaya encima:
[source,lua]
----
spriteDraw(shipSprite, shipX, shipY)
if SHOW_BOXES then
drawBoxes()
end
drawHud()
----
Cambia `SHOW_BOXES` a `true` y aparecen contornos verdes sobre el arte, como en
la imagen del principio de esta lección. Vuelve a cambiarlo cuando acabes. Un
interruptor como este, una palabra arriba del todo de un archivo que enciende
una imagen de lo que cree el programa, vale más que cualquier cantidad de mirar
fijamente.
[#_what_just_happened_7]
=== Qué acaba de pasar
[source,lua]
----
function rockBox(rock)
return rock.x + ROCK_PAD, rock.y + ROCK_PAD, rockWidth - ROCK_PAD * 2, rockHeight - ROCK_PAD * 2
end
----
Cuatro valores de un solo `return`, separados por comas. Lua devuelve encantado
tantos como quieras, y quien llama decide cuántos se queda. Cuando una llamada
así es lo *último* dentro de los paréntesis de otra llamada, se pasan los
cuatro, y por eso `drawBox(rockBox(rock))` funciona y le da a `drawBox` sus
cuatro argumentos. En cualquier otro sitio de una lista de argumentos, solo
sobrevive el primer valor. Esa regla muerde exactamente una vez, y muerde al
final de esta lección.
[source,lua]
----
return collideRects(sx, sy, sw, sh, rx, ry, rw, rh)
----
`collideRects` quiere ocho números: esquina y tamaño, esquina y tamaño.
Devuelve `true` o `false` y no cambia nada. Como la respuesta sale directamente
de la función con `return`, `shipHitsRock(rock)` se lee como una pregunta allí
donde se use.
[source,lua]
----
return collidePointRect(shot.x + shotWidth / 2, shot.y, rx, ry, rw, rh)
----
`collidePointRect` quiere seis: un punto, y luego un rectángulo. El borde
cuenta como dentro, así que un disparo justo en el límite acierta. Úsala
siempre que uno de los lados de la pregunta sea lo bastante pequeño como para
que su tamaño no importe.
[source,lua]
----
colorForeground(0, 255, 0)
overlayBox(x, y, x + width - 1, y + height - 1)
----
Las dos vienen de la lección siete. `overlayBox` recibe dos *esquinas*, no una
esquina y un tamaño -- el único sitio de esta lección donde los números cambian
de forma -- y el `- 1` está porque las dos esquinas se incluyen.
`colorForeground` es un interruptor que se queda puesto, así que todo lo que
dibuja `drawBoxes` sale verde. Los sprites y `overlayPrint` no le hacen caso,
así que el resto del juego se ve igual.
[#_the_rest_of_the_family]
=== El resto de la familia
`collideRects` y `collidePointRect` bastan para la mayoría de los juegos 2D.
Las demás están para las formas que los rectángulos describen mal, y cada una
es el mismo tipo de pregunta sencilla:
* `collideCircles` y `collidePointCircle`, para cualquier cosa redonda. Un
círculo es un centro y un radio.
* `collideRectCircle`, para una cosa redonda contra una cuadrada.
* `collidePointPolygon`, para una forma con esquinas propias -- un dragón, un
continente, un botón con forma rara. Le das una lista plana de números `x`,
`y`, `x`, `y`.
* `collideSegments`, para dos segmentos de línea que se cruzan.
Si eres sincero, tu roca es redonda, y las comprobaciones de círculos le van
mejor que cualquier caja. Los márgenes son la respuesta barata y bastan para la
mayoría de los juegos, y por eso van primero. La respuesta exacta es una de las
cosas que probar más abajo.
`collideSegments` importa más de lo que parece. Tu disparo se mueve seis
píxeles por fotograma y las rocas tienen dieciséis píxeles de zona de impacto,
así que un disparo no puede atravesar una sin que se le compruebe dentro de
ella. Haz el disparo lo bastante rápido -- cuarenta píxeles por fotograma -- y
salta de debajo de una roca a encima de ella, sin estar ni una sola vez en el
mismo sitio que la roca, y nunca le da a nada. Trazar la línea desde donde
estaba el disparo hasta donde está ahora, y preguntarle a `collideSegments` si
esa línea cruza un borde, es la cura. Las cosas lentas no la necesitan.
Hay un callback de colisión en Singe, `onCollision`, y no es para esto.
Pertenece al motor de física 3D: cuerpos con masa que caen y rebotan unos
contra otros en tres dimensiones y te dicen dónde se han tocado y con qué
fuerza. Lo conocerás en la lección veinticuatro. Nada del overlay avisa por sí
solo, y nada lo necesita. Tus rocas están en una lista, sabes cuántas hay, y
preguntar es barato.
Y cuando quieras saber no solo *si* dos cosas se han encontrado, sino dónde
debería acabar la que se mueve -- deslizándose a lo largo de una pared en lugar
de quedarse clavada dentro de ella -- las llamadas de colisión no responden a
eso, a propósito. Singe incluye una biblioteca llamada `bump` justo para ese
trabajo, y la sección Bibliotecas incluidas de la referencia dice cómo usarla.
[#_try_it_11]
=== Pruébalo
. *Quita los márgenes.* Pon `ROCK_PAD` a `0` y juega un minuto. Luego ponlo a
`8` y vuelve a jugar. Con uno de los dos parece que el juego te miente.
. *Mira lo que estás comprobando.* Pon `SHOW_BOXES` a `true` y observa los
contornos verdes mientras una roca pasa junto a la nave. Ahora pon
`SHIP_PAD_X` a `16`, juega, y fíjate en que ya nada puede darte. Averigua por
qué con la aritmética de `shipBox`.
. *Haz el disparo demasiado rápido.* Pon `SHOT_SPEED` a `40`. Cuenta a cuántas
rocas de cada diez les das. Averigua con los números por qué pasa eso antes
de volver a cambiarlo.
. *Usa el disparo entero.* Reescribe `shotHitsRock` para que use `collideRects`
con el rectángulo completo del disparo en lugar de `collidePointRect` con su
punta. Juega con las dos y decide cuál prefieres; no hay respuesta correcta,
y vale la pena saberlo.
. *Haz la roca redonda.* Dale a la roca un círculo en lugar de una caja: su
centro está a `rock.x + rockWidth / 2` en horizontal y a
`rock.y + rockHeight / 2` en vertical, y un radio de `10` es más o menos lo
justo. Usa `collideRectCircle` para la nave y `collidePointCircle` para el
disparo, y dibuja el círculo en `drawBoxes` con `overlayCircle`, que recibe
un centro y un radio.
[#_break_it_on_purpose_10]
=== Rómpelo a propósito
Las dos cajas ya son cuatro valores cada una, así que dan ganas de pasarlas
directamente y ahorrarse los ocho nombres locales:
[source,lua]
----
function shipHitsRock(rock)
return collideRects(shipBox(), rockBox(rock))
end
----
Parece correcto y está mal. Ejecútalo y el juego se para antes de dibujar nada:
----
186:collideRects: Expected 8 argument(s), got 5.
----
La línea es la que acabas de escribir, así que puede que la tuya no sea la 186.
El cinco, en cambio, sí. `rockBox(rock)` es lo último dentro de los paréntesis,
así que entraron sus cuatro valores. `shipBox()` no lo es, así que se recortó a
uno. Uno más cuatro son cinco, y `collideRects` no va a adivinar los otros
tres.
Este error viene del motor y no de Lua, así que tiene la misma forma que los de
las lecciones nueve y diez: el número del principio es la línea, luego viene el
nombre de la función que se ha negado, y luego la queja. Vuelve a poner los
ocho nombres locales.
[#_what_you_learned_11]
=== Lo que has aprendido
* Las colisiones son aritmética con rectángulos, y puedes escribirla tú mismo.
* Dos rectángulos pueden no tocarse exactamente de cuatro formas; si no se
cumple ninguna, chocan.
* `collideRects` recibe una esquina y un tamaño para cada uno de dos
rectángulos y responde `true` o `false`. Cuenta como que se tocan los bordes
que solo se rozan.
* Las comprobaciones de colisión son parte del kit de juego, funciones
sencillas sin nada que preparar. Partida guardada, Temporizador,
Interpolación y Estadísticas son el resto.
* `collidePointRect` pregunta si un punto está dentro de un rectángulo, que es
la pregunta adecuada para cualquier cosa pequeña.
* La caja que compruebas es más pequeña que la imagen que dibujas, o el juego
parecerá injusto.
* Una función puede devolver varios valores, y solo la última llamada de una
lista de argumentos los pasa todos.
* Las comprobaciones de colisión solo responden. Lo que pasa después es cosa de
tu código, y mantener las dos partes separadas te deja cambiar cualquiera de
ellas por su cuenta.
* Dibujar tus zonas de impacto en pantalla convierte un fallo invisible en uno
visible.
* `onCollision` es para cuerpos de física 3D, no para sprites en el overlay.
[#_next_time_11]
=== La próxima vez
El juego cuenta tu puntuación y te quita vidas, y en cuanto sales, la mejor
puntuación que nadie haya conseguido desaparece. Empieza en plena acción en el
instante en que se carga. Y lleva la cuenta de lo que está haciendo con dos
variables de verdadero o falso, `over` y `dying`, que es exactamente la trampa
de la que te avisó la lección siete. La lección doce le da una pantalla de
título, una sola variable en lugar de esas dos y un récord que mañana seguirá
ahí.