singe/docs/es/lessons/25-particles.adoc

548 lines
21 KiB
Text

[#_lesson_25_particles]
== Lección 25: Partículas
image::learn/25-particles.png[La lección terminada, 480]
Al final de esta lección, una nave cruzará la parte de abajo de la pantalla
dejando un rastro de fuego detrás de su motor, y un botón la hará estallar en
ciento cuarenta chispas que giran y rebotan en el suelo. No escribirás ni una
sola línea que dibuje una chispa.
Esa es toda la idea de esta lección. Tú describes el fuego; el motor lo hace.
[#_why_you_do_not_draw_the_sparks]
=== Por qué no dibujas tú las chispas
Podrías hacerlo al revés. Sabes lo suficiente: una tabla de chispas de la
lección seis, cada una con una x, una y, y una velocidad; un bucle en
`onOverlayUpdate` que las mueve todas, las desvanece, quita cada una cuando
muere y la dibuja. Funciona. También son doscientas líneas de contabilidad que
cada juego vuelve a escribir, y se ejecuta en Lua, una chispa cada vez, sesenta
veces por segundo.
En lugar de eso, Singe lo hace por ti. Le das una *receta* -- cuántas chispas
por segundo, cuánto vive cada una, a qué velocidad y hacia dónde salen, qué tira
de ellas, qué aspecto tienen y a qué color se desvanecen -- y él se encarga de
las chispas. Lo que guarda la receta y la ejecuta se llama *emisor*.
Un emisor no es una imagen ni un objeto de tu juego. Es la descripción de un
tipo de fuego, guardada en algún sitio que puedes señalar, y puede hacer ese
fuego en cualquier sitio que le pidas.
[#_start_with_something_that_moves]
=== Empieza con algo que se mueve
Las partículas resultan más convincentes cuando salen de algo, así que empieza
con una nave. Crea una carpeta, copia dentro la carpeta `art` del libro y pon
esto en `particles.singe`:
[source,lua]
----
dofile("Singe/Framework.singe")
local SHIP_WIDTH = 32
local SHIP_HEIGHT = 24
local SHIP_SPEED = 120
local shipSprite = spriteLoad(DIR .. "art/spaceship.png")
local shipX = (overlayGetWidth() - SHIP_WIDTH) / 2
local shipY = overlayGetHeight() - 56
local movingLeft = false
local movingRight = false
local lastTicks = singeGetTicks()
function onInputPressed(what)
if what == SWITCH_LEFT then
movingLeft = true
elseif what == SWITCH_RIGHT then
movingRight = true
end
end
function onInputReleased(what)
if what == SWITCH_LEFT then
movingLeft = false
elseif what == SWITCH_RIGHT then
movingRight = false
end
end
function onOverlayUpdate()
local now = singeGetTicks()
local seconds = (now - lastTicks) / 1000
lastTicks = now
if movingLeft then
shipX = shipX - SHIP_SPEED * seconds
end
if movingRight then
shipX = shipX + SHIP_SPEED * seconds
end
overlayClear()
spriteDraw(shipSprite, shipX, shipY)
return OVERLAY_UPDATED
end
----
Ejecútalo con `Singe -R particles` y maneja la nave con las teclas de flecha
izquierda y derecha. Aquí no hay nada nuevo: un sprite de la lección nueve, los
interruptores de la lección tres y `singeGetTicks` para que la nave se mueva a
la misma velocidad sea cual sea la tasa de fotogramas.
La única línea que merece la pena señalar es `dofile("Singe/Framework.singe")`.
Es la que define `DIR`, la carpeta donde está tu script, así que
`DIR .. "art/spaceship.png"` encuentra la imagen sea cual sea la carpeta desde
la que arrancaste Singe.
[#_give_it_an_exhaust]
=== Ponle un escape
Ahora el fuego. Añade esto encima de los callbacks, después de las variables de
la nave:
[source,lua]
----
local exhaust = emitterNew()
emitterSetBlend(exhaust, PARTICLE_ADD)
emitterSetRate(exhaust, 90)
emitterSetLife(exhaust, 0.15, 0.40)
emitterSetDirection(exhaust, 0, 1)
emitterSetSpread(exhaust, 12)
emitterSetSpeed(exhaust, 40, 90)
emitterSetSize(exhaust, 7, 1)
emitterSetColor(exhaust, 255, 240, 180, 255, 255, 60, 0, 0)
emitterSetMax(exhaust, 120)
emitterStart(exhaust)
----
Y dos líneas dentro de `onOverlayUpdate`. La primera va justo antes de
`overlayClear` y la segunda justo después de `spriteDraw`:
[source,lua]
----
emitterSetPosition(exhaust, shipX + SHIP_WIDTH / 2, shipY + SHIP_HEIGHT - 2)
----
[source,lua]
----
emitterDraw(exhaust)
----
Guarda. Ahora la nave va montada sobre una lengua corta de llama amarilla, y la
llama la acompaña cuando la manejas.
[#_blow_it_up]
=== Hazla estallar
Un escape funciona para siempre. Una explosión ocurre una sola vez, y para el
motor eso es otro tipo de petición al mismo emisor.
Antes, dos archivos más y un número más. Estos van junto al sprite de la nave,
arriba del todo:
[source,lua]
----
local sparkSprite = spriteLoad(DIR .. "art/star.png")
local boomSound = soundLoad(DIR .. "art/boom.wav")
----
y esto va junto a `shipX` y `shipY`, veinte unidades por encima de la parte de
abajo de la pantalla:
[source,lua]
----
local ground = overlayGetHeight() - 20
----
Ahora la segunda receta, debajo de la primera:
[source,lua]
----
local boom = emitterNew()
emitterSetTexture(boom, sparkSprite)
emitterSetBlend(boom, PARTICLE_ADD)
emitterSetLife(boom, 0.4, 1.1)
emitterSetSpeed(boom, 40, 220)
emitterSetSpread(boom, 180)
emitterSetGravity(boom, 0, 160)
emitterSetDrag(boom, 0.9)
emitterSetSize(boom, 10, 2, 0.4)
emitterSetSpin(boom, -240, 240)
emitterSetColor(boom, 255, 255, 220, 255, 255, 70, 20, 0)
emitterSetCollide(boom, COLLIDE_FLOOR, 0.35, 0.3, ground)
emitterSetTrail(boom, 4, 2)
emitterSetMax(boom, 400)
----
Fíjate en que falta `emitterStart`. Este no va a soltar nunca un chorro.
Añade una función que lo dispare, encima de `onInputPressed`:
[source,lua]
----
function explode()
if not flying then
return
end
flying = false
emitterStop(exhaust)
emitterSetPosition(boom, shipX + SHIP_WIDTH / 2, shipY + SHIP_HEIGHT / 2)
emitterBurst(boom, 140)
soundPlay(boomSound)
timerAfter(RESPAWN_MS, function()
flying = true
emitterStart(exhaust)
end)
end
----
Para eso hacen falta tres cosas más: `local RESPAWN_MS = 1400` con las demás
constantes, `local flying = true` con las demás variables y una rama en
`onInputPressed`:
[source,lua]
----
elseif what == SWITCH_BUTTON1 then
explode()
----
Por último, la nave no debería estar ahí mientras está hecha pedazos. Envuelve
el manejo y el dibujo de `onOverlayUpdate` en `if flying then ... end`, y añade
`emitterDraw(boom)` junto a la otra llamada de dibujo. El script terminado es
`learn/25-particles.singe`.
Pulsa la *barra espaciadora* -- el botón uno por defecto -- y la nave se deshace
en una nube de estrellas que dan vueltas, caen en arco, chocan contra el suelo y
rebotan.
[#_what_just_happened_21]
=== Qué acaba de pasar
En esta lección hay muchísimas llamadas nuevas, y todas tienen la misma forma:
primero el emisor y luego lo que estás ajustando. Míralas por grupos.
[source,lua]
----
local exhaust = emitterNew()
----
Crea un emisor y te devuelve un *handle*: un número que el motor usa para
volver a encontrarlo, exactamente igual que el número que te da `spriteLoad`.
Sin argumento obtienes un emisor bidimensional, que vive en coordenadas del
overlay y se dibuja desde `onOverlayUpdate`. Dale un nodo a `emitterNew` y
obtienes uno tridimensional, que vive en la escena y se dibuja solo; todo lo
demás de esta lección es igual para los dos.
Un emisor nuevo ya tiene una receta completa -- cincuenta partículas por
segundo, blancas, un segundo o dos de vida, subiendo despacio por la pantalla --
así que con cada llamada posterior le llevas la contraria a un valor por
defecto. La entrada de la referencia para `emitterNew` los enumera todos.
[source,lua]
----
emitterSetRate(exhaust, 90)
emitterSetLife(exhaust, 0.15, 0.40)
----
Noventa partículas por segundo, cada una viviendo entre quince centésimas y
cuatro décimas de segundo. Esos dos números juntos deciden cuánto fuego hay:
noventa por segundo que viven un tercio de segundo son unas treinta en pantalla
en cada momento.
Siempre que una llamada de emisor recibe un mínimo y un máximo, cada partícula
recibe su propio valor, elegido al azar entre los dos. De ahí viene la
irregularidad. Un fuego en el que cada llama viviera exactamente lo mismo que
todas las demás parecería una máquina.
[source,lua]
----
emitterSetDirection(exhaust, 0, 1)
emitterSetSpread(exhaust, 12)
emitterSetSpeed(exhaust, 40, 90)
----
Hacia dónde salen, lo ancho que es el abanico y a qué velocidad. La Y del
overlay crece *hacia abajo*, como desde la lección uno, así que `(0, 1)` es
hacia abajo en la pantalla -- por la parte de atrás de una nave que apunta hacia
arriba. `emitterSetSpread` es la mitad del ángulo del abanico: doce grados es un
haz estrecho, noventa lo abre a medio círculo y ciento ochenta es todas las
direcciones a la vez.
[source,lua]
----
emitterSetSize(exhaust, 7, 1)
emitterSetColor(exhaust, 255, 240, 180, 255, 255, 60, 0, 0)
----
Estas dos son la razón de que la llama parezca una llama. Una partícula no tiene
un tamaño y un color; tiene un tamaño y un color *al nacer* y otros *al morir*,
y pasa de manera uniforme de unos a otros a lo largo de la vida que le haya
tocado. Así que cada chispa empieza con siete unidades del overlay de ancho y de
color amarillo pálido, y acaba con una unidad de ancho y de color rojo anaranjado
con un alfa de cero, que es invisible. Encoge y se desvanece porque tú dijiste
dónde acaba, no porque algo la desvaneciera.
`emitterSetColor` recibe ocho números, que es mucho que leer: rojo, verde, azul
y alfa al principio, y luego rojo, verde, azul y alfa al final.
[source,lua]
----
emitterSetBlend(exhaust, PARTICLE_ADD)
----
Cómo se combina una partícula con lo que tiene detrás. `PARTICLE_ALPHA` es
pintar normal y es el valor por defecto: úsalo para humo, polvo, lluvia y
escombros. `PARTICLE_ADD`, en cambio, suma su luz a la imagen, así que dos
chispas una encima de otra brillan más que una y nada se oscurece nunca. El
fuego, las chispas, los láseres y la magia son aditivos. El humo no, y el humo
dibujado de forma aditiva parece vapor iluminado desde dentro.
[source,lua]
----
emitterSetMax(exhaust, 120)
emitterStart(exhaust)
----
`emitterSetMax` es el tamaño de la reserva: cuántas partículas de este emisor
pueden existir a la vez. Cuando la reserva está llena no nace ninguna nueva
hasta que muere alguna. Noventa por segundo que viven hasta cuatro décimas de
segundo necesitan unas treinta y seis, así que ciento veinte va holgado.
Ajústalo antes de arrancar, porque cambiarlo después tira todas las partículas
vivas.
`emitterStart` abre el grifo. A partir de ahí el emisor crea partículas en cada
fotograma, lo dibujes o no, hasta que `emitterStop` lo vuelve a cerrar. Fíjate
en lo que `emitterStop` *no* hace: detiene las partículas nuevas, y las que ya
están vivas terminan su vida y se desvanecen con normalidad. Por eso el escape
se va apagando en lugar de desaparecer de golpe cuando la nave explota. Si las
quieres fuera en este mismo instante, `emitterClear` las mata.
[source,lua]
----
emitterSetPosition(exhaust, shipX + SHIP_WIDTH / 2, shipY + SHIP_HEIGHT - 2)
----
Así es como un emisor sigue a algo. Un emisor bidimensional crea sus partículas
en un punto, y ese punto es donde lo pusiste la última vez, así que lo ajustas
en cada fotograma a partir de lo que se esté moviendo: la posición de un sprite,
el nodo de un cuerpo físico, el ratón. Las partículas nuevas aparecen en el
sitio nuevo; las que ya están en el aire se quedan donde nacieron, que es justo
lo correcto para un escape, porque el escape se queda atrás.
Un emisor tridimensional se crea sobre un nodo y sigue a ese nodo por su cuenta,
sin ninguna llamada en tu actualización. Y cuando *sí* quieres que toda la nube
viaje con la cosa -- un escudo, un aura, la llama de un propulsor vista desde
fuera -- `emitterSetLocal` lo activa.
[source,lua]
----
emitterDraw(exhaust)
----
Los emisores bidimensionales se dibujan cuando lo pides, una vez por fotograma,
desde `onOverlayUpdate`. El orden de tus llamadas a `emitterDraw` es el orden en
que se apilan. Olvidar esta llamada es el error que todo el mundo comete una
vez: el emisor sigue funcionando, sigue gastando su reserva y no muestra nada.
Ahora las llamadas propias de la explosión.
[source,lua]
----
emitterSetTexture(boom, sparkSprite)
----
Sin textura, cada partícula es una mancha redonda y suave que dibuja el propio
motor, que es lo que usa el escape y lo adecuado para una llama. Con una, cada
partícula lleva puesta esa imagen -- aquí `star.png`, de ocho por ocho píxeles.
El emisor se queda con su propia copia de los píxeles, así que puedes descargar
el sprite después y las partículas siguen funcionando.
[source,lua]
----
emitterSetGravity(boom, 0, 160)
emitterSetDrag(boom, 0.9)
----
La gravedad es un tirón constante, en unidades del overlay por segundo por
segundo, y es positiva hacia abajo por el mismo motivo por el que el escape sale
con una Y positiva. El rozamiento es lo contrario: quita velocidad, un poco en
cada fotograma, así que las chispas salen disparadas con fuerza y luego se
quedan suspendidas. Las chispas con gravedad y sin rozamiento caen en línea
recta como un fuego artificial; el rozamiento es lo que hace que parezca que se
mueven a través del aire.
[source,lua]
----
emitterSetSpin(boom, -240, 240)
emitterSetSize(boom, 10, 2, 0.4)
----
El giro va en grados por segundo, y un rango que cruza el cero te da algunas que
giran hacia cada lado. Se nota en una textura y no le hace nada en absoluto a la
mancha incorporada, que es redonda.
El tercer número de `emitterSetSize` es la variación. Escala los dos tamaños de
cada partícula por un mismo factor aleatorio -- aquí cualquier valor entre seis
décimas y uno con cuatro décimas -- para que las chispas no sean todas la misma
estrella del mismo tamaño.
[source,lua]
----
emitterSetCollide(boom, COLLIDE_FLOOR, 0.35, 0.3, ground)
emitterSetTrail(boom, 4, 2)
----
`COLLIDE_FLOOR` pone un suelo plano e invisible de lado a lado del mundo a la
altura que indiques, y las partículas rebotan en él. Los dos números que van
antes son cuánta velocidad sobrevive a un rebote y cuánta velocidad lateral se
pierde. No cuesta nada, porque es una comparación por partícula, y hace que los
restos parezcan haber caído en algún sitio en lugar de atravesar el mundo. En un
juego tridimensional, `COLLIDE_SCENE` hace rebotar las partículas en la
geometría real, lo que cuesta muchísimo más.
Una estela guarda los últimos sitios por los que ha pasado una partícula y
dibuja a través de ellos una cinta que se desvanece. Cuatro posiciones y dos
unidades de ancho convierten cada estrella en un trazo corto.
[source,lua]
----
emitterBurst(boom, 140)
----
Y esta es la otra forma de crear partículas. `emitterStart` es un grifo;
`emitterBurst` es un puñado lanzado de golpe. El emisor no tiene que estar
soltando un chorro, y este nunca lo está. Todo lo demás -- la receta, la
reserva, la posición -- funciona igual.
Merece la pena quedarse con esta distinción. Todo lo continuo es un chorro que
abres y cierras: escape, humo, lluvia, una antorcha, una cascada. Todo lo que
pasa en un momento es una ráfaga: una explosión, una salpicadura, una pisada en
el polvo, el impacto de una bala, el fogonazo de un disparo. Muchos efectos son
las dos cosas, con un emisor o con dos: un proyectil estalla y luego echa humo.
[#_how_many_is_too_many]
=== Cuántas son demasiadas
La respuesta sincera es que las partículas son baratas de mover y caras de
mirar.
Mover diez mil partículas es aritmética, y un ordenador capaz de ejecutar un
juego puede hacerlo sin enterarse. Lo que cuesta es dibujarlas, y lo que cuesta
dibujarlas es más o menos el número de píxeles de pantalla que cubren las
partículas, contando los solapamientos. Doscientas chispas de ocho píxeles de
ancho cubren menos pantalla que una bocanada de humo que la llena entera. Esa es
la regla: pequeñas y muchas es casi gratis, y grandes y solapadas no.
La mezcla aditiva es la cara, porque nada de lo que dibuja se puede saltar --
cada capa se suma a lo que hay debajo, así que la máquina las pinta todas. Una
docena de destellos aditivos a pantalla completa uno encima de otro dejarán una
Raspberry Pi 4 a paso de tortuga, mientras que diez mil chispitas que rebotan no
la inmutan.
Así que unos pocos miles de partículas pequeñas no son motivo de preocupación.
Antes de pasar de ahí, pon el recuento en pantalla y vigílalo.
[source,lua]
----
overlayPrint(2, 4, "Particles alive: " .. (emitterGetCount(exhaust) + emitterGetCount(boom)))
----
`emitterGetCount` es cómo dimensionas una reserva con honradez en lugar de a
ojo. Si el número se queda exactamente en tu `emitterSetMax`, la reserva está
llena y el motor se está negando en silencio a crear partículas que le has
pedido. Si nunca se acerca, estás reservando memoria para nada.
[#_try_it_25]
=== Pruébalo
. *Convierte el escape en humo.* Cambia `emitterSetBlend(exhaust,
PARTICLE_ADD)` por `PARTICLE_ALPHA`, pon el color en gris que se desvanece a
gris transparente y haz que crezca en lugar de encoger con
`emitterSetSize(exhaust, 3, 14)`. Tres líneas han hecho una máquina distinta.
. *Quítale el rozamiento a la explosión.* Borra la línea `emitterSetDrag` y
compara. Luego vuelve a poner el rozamiento y quita la gravedad. Una de las
dos parece fuegos artificiales y la otra parece un accidente.
. *Ensancha el escape.* Pon `emitterSetSpread(exhaust, 180)`. La nave deja de
parecer que tiene un motor. La apertura del abanico hace más de lo que crees.
. *Lanza mil.* Cambia `emitterBurst(boom, 140)` por
`emitterBurst(boom, 1000)` y mira el recuento en pantalla. No llegará a mil.
Averigua por qué a partir de `emitterSetMax`, luego agranda la reserva, haz
las chispas cuatro veces más grandes y mira qué le pasa a la tasa de
fotogramas.
. *Lanza la explosión donde no está la nave.* Pon `emitterSetPosition(boom,
40, 60)` en `explode` en lugar de la posición de la nave. Un emisor es la
descripción de un fuego, no una cosa que viva en algún sitio, y puedes lanzar
una ráfaga del mismo emisor en diez sitios en el mismo fotograma.
[#_break_it_on_purpose_24]
=== Rómpelo a propósito
Las constantes -- `PARTICLE_ADD`, `COLLIDE_FLOOR`, `OVERLAY_UPDATED` -- son
nombres que Singe les ha dado a números. Escribe una un poco mal y Lua no se
queja, porque en Lua un nombre al que nunca se le dio un valor no es un error:
es `nil`, el valor que significa «aquí no hay nada».
Cambia la línea de mezcla del escape por un nombre que parece igual de
probable:
[source,lua]
----
emitterSetBlend(exhaust, PARTICLE_ADDITIVE)
----
Singe se para antes de que arranque el juego, nombrando la línea de tu archivo
en la que está esa llamada:
----
22:emitterSetBlend: Argument 2 must be a number.
----
Este error tiene una forma distinta de los errores de Lua de la lección ocho,
porque viene del motor y no de Lua. Es el número de línea, luego la llamada que
protestó y luego la queja. El argumento dos de `emitterSetBlend` es el modo de
mezcla, y lo que llegó fue `nil`, porque `PARTICLE_ADDITIVE` no es un nombre que
nadie haya definido nunca.
«Must be a number» casi siempre significa esto: una constante mal escrita, o una
variable que creías haber asignado. El arreglo es comparar el nombre con la
referencia, que lo escribe `PARTICLE_ADD`.
[#_what_you_learned_25]
=== Lo que has aprendido
* Un emisor guarda la receta de un tipo de partícula y se encarga él mismo de
las partículas, así que tu script nunca dibuja ninguna.
* `emitterNew` sin argumento crea un emisor bidimensional en coordenadas del
overlay; con un nodo crea uno tridimensional en la escena.
* Todos los emisores empiezan con una receta completa, y cada llamada
`emitterSet...` cambia una parte de ella.
* Siempre que una llamada recibe un mínimo y un máximo, cada partícula saca su
propio valor al azar entre los dos, y de ahí viene la irregularidad.
* El tamaño y el color se fijan al nacer y al morir, y la partícula pasa de uno
a otro a lo largo de su vida.
* `emitterStart` suelta un chorro hasta `emitterStop`; `emitterBurst` lanza un
puñado de golpe. Los efectos continuos son chorros, los momentáneos son
ráfagas.
* `emitterStop` deja que las partículas vivas terminen; `emitterClear` las mata
ya.
* Un emisor bidimensional se mueve con `emitterSetPosition` en cada fotograma y
se dibuja con `emitterDraw` en cada fotograma. Uno tridimensional sigue a su
nodo y se dibuja solo.
* Las partículas cuestan lo que cubren en pantalla, no lo que cuesta moverlas, y
la mezcla aditiva es la cara.
* `emitterSetMax` pone un tope a la reserva, y `emitterGetCount` te dice si
acertaste con el tamaño.
[#_next_time_25]
=== La próxima vez
La explosión movió ciento cuarenta cosas sin que nadie le dijera adónde tenía
que ir ninguna, pero eran chispas, y a las chispas les da igual con qué choquen.
La lección veintiséis trata de algo a lo que no le da igual: un personaje que
tiene que llegar de aquí a allí, con una pared en medio.