singe/docs/es/lessons/16-branching.adoc

540 lines
20 KiB
Text

[#_lesson_16_branching]
== Lección 16: Ramificaciones
image::learn/16-branching.png[La lección terminada, 480]
Un juego de vídeo con ramificaciones es una película con bifurcaciones. Se
reproduce una escena, el jugador elige, y a continuación se reproduce una escena
distinta. Haz eso veinte o treinta veces y tendrás un juego al que la gente
volverá durante años intentando encontrar el final que no ha visto.
En esta lección vas a construir uno. Tiene cuatro escenas, dos elecciones, un
temporizador en cada elección, una muerte y tres vidas. El código que lo
reproduce tiene unas treinta líneas, y seguiría teniendo unas treinta líneas si
el juego tuviera doscientas escenas -- porque el juego en sí no va a estar
escrito en código en absoluto.
[#_imagine_it_is_a_dragon]
=== Imagina que es un dragón
El vídeo que tiene todo el mundo es `Singe/menuBackground.mkv`, los mismos 420
fotogramas que llevas manejando desde la lección catorce. Es una bola de fuego,
un dragón hecho de papel, la palabra `SINGE` y luego una cuadrícula magenta que
se aleja rodando bajo un sol naranja, para siempre.
No es un drama. No sale nadie que pueda estar en peligro, y al atardecer le da
igual lo que decidas.
Sígueme el juego de todos modos. El dragón es lo bastante real durante dos
minutos, y todo lo que vas a escribir funciona exactamente igual cuando las
imágenes son de un especialista cayendo por un acantilado. Aquí tienes el mapa,
y los números de fotograma son los que encontraste tú mismo en la lección
catorce:
[cols="1,1,3",options="header"]
|===
| Escena | Fotogramas | Qué pasa
| `arrive` | 0 a 119 | Fuego, y el dragón aterriza. *¿Izquierda o derecha?*
| `sunset` | 120 a 259 | Echa fuego. *¿Te agachas a la izquierda o corres a la derecha?*
| `pit` | 260 a 339 | Te come. Pierdes una vida.
| `escape` | 340 a 410 | La cuadrícula se aleja y has escapado.
|===
Lo diestro, por así decirlo, es ir siempre a la izquierda: la izquierda desde
`arrive` lleva a `sunset`, y la izquierda desde `sunset` te lleva a `escape`. La
derecha lleva a `pit` las dos veces, y tardar demasiado también.
[#_the_map_is_data_not_code]
=== El mapa son datos, no código
Aquí tienes el juego entero, y es una tabla.
[source,lua]
----
local scenes = {
arrive = {
first = 0,
last = 119,
ask = 30,
prompt = "The dragon lands. Left or Right?",
left = "sunset",
right = "pit",
goesTo = "pit"
},
sunset = {
first = 120,
last = 259,
ask = 170,
prompt = "It breathes fire. Duck Left or run Right?",
left = "escape",
right = "pit",
goesTo = "pit"
},
pit = {
first = 260,
last = 339,
death = true,
goesTo = "arrive"
},
escape = {
first = 340,
last = 410
}
}
----
Una tabla con nombres a la izquierda en lugar de números, exactamente como en la
lección seis, y cada valor de dentro es otra tabla. Cada una de esas tablas
interiores es un *registro*: una escena, descrita campo a campo.
`scenes.arrive` es la primera escena. `scenes.arrive.first` es `0`.
`scenes.arrive.left` es la cadena `"sunset"`, que es el nombre de otra escena de
la misma tabla -- y así es como se apuntan las bifurcaciones. Una escena no
contiene la escena que va después de ella. Contiene el *nombre* de la escena que
va después, y el nombre se busca cuando llega el momento.
Los campos significan esto:
`first` y `last`:: Los fotogramas entre los que va la escena.
`ask`:: El fotograma en el que aparece la pregunta. El jugador tiene de `ask` a
`last` para responder, y ese es el temporizador.
`prompt`:: Lo que dice la pregunta.
`left` y `right`:: La escena a la que lleva cada respuesta.
`goesTo`:: Adónde va la escena cuando nadie ha elegido -- porque al jugador se le
acabó el tiempo, o porque para empezar no había ninguna pregunta. En `pit`, que
es una muerte, es adonde vuelve el reintento.
`death`:: Está presente, y vale `true`, solo en una escena que cuesta una vida.
Hay tres cosas en las que vale la pena fijarse en esa tabla.
La primera es que es el documento de diseño. Puedes dársela a alguien que no ha
escrito nunca una línea de Lua y sacará el juego de ella con solo leerla. Cuando
quieras que el dragón se bifurque en tres caminos, añades un campo. Cuando
quieras otra escena, añades otro registro. No vas a buscar el código que
reproduce las escenas, porque no lo cambias.
La segunda es que los registros no tienen todos la misma forma. `pit` no tiene
`ask`, ni `prompt`, ni `left` -- en él no hay nada que elegir. `escape` no tiene
`goesTo`, porque es el final y no va a ninguna parte. Un campo que no está en
una tabla se lee como `nil`, que es la palabra que conociste en la lección tres
para «ahí no hay nada», y pedir `scenes.escape.goesTo` no es un error. Es `nil`,
y `nil` es una respuesta. El código que lee estos registros tiene que estar
preparado para eso, y dentro de un momento verás la única línea que lo está.
La tercera es lo que la tabla *no* contiene: nada de ella dibuja, reproduce ni
espera. Es una descripción. Esa separación es todo el truco de esta lección.
[#_one_function_that_plays_any_scene]
=== Una función que reproduce cualquier escena
Esta es la mitad hecha de código, y solo hay una función de cierto tamaño.
Escrita en el modelo con hilo de la lección quince, porque un juego con
ramificaciones es lo más secuencial que existe.
[source,lua]
----
local function playScene(name)
local scene = scenes[name]
discSkipToFrame(scene.first)
while discGetFrame() < scene.last do
local frame = discGetFrame()
local asking = scene.ask ~= nil and frame >= scene.ask
if not asking then
answer = nil
elseif answer == "left" or answer == "right" then
return scene[answer]
end
drawScene(scene, frame, asking)
singeYield()
end
discPause()
if scene.death then
lives = lives - 1
if lives == 0 then
ending = "The dragon wins. Game over."
return nil
end
waitForGo("Press the space bar to try again.")
end
return scene.goesTo
end
----
Recibe el nombre de una escena, la reproduce y devuelve el nombre de la escena
que va a continuación. Ese es todo su contrato, y entonces el juego son tres
líneas:
[source,lua]
----
function singeMain()
local scene = "arrive"
while scene ~= nil do
scene = playScene(scene)
end
waitForGo(ending .. " Space to quit.")
end
----
Empieza en `arrive`. Reproduce la escena que tengas en la mano, y quédate con lo
que te devuelva. Cuando te devuelva `nil` no hay escena siguiente, el bucle
termina y la partida se acaba.
[#_the_whole_thing_8]
=== Todo junto
Crea `dragon.singe` en tu carpeta `movie` y escribe esto. Es el script más largo
del libro hasta ahora, y cada parte de él ya está explicada salvo las dos
pequeñas funciones auxiliares, que vienen después.
[source,lua]
----
local FPS = 30
local scenes = {
arrive = {
first = 0,
last = 119,
ask = 30,
prompt = "The dragon lands. Left or Right?",
left = "sunset",
right = "pit",
goesTo = "pit"
},
sunset = {
first = 120,
last = 259,
ask = 170,
prompt = "It breathes fire. Duck Left or run Right?",
left = "escape",
right = "pit",
goesTo = "pit"
},
pit = {
first = 260,
last = 339,
death = true,
goesTo = "arrive"
},
escape = {
first = 340,
last = 410
}
}
local answer = nil
local ending = "You got out alive."
local lives = 3
local function drawScene(scene, frame, asking)
overlayClear()
overlayPrint(2, 2, "Lives: " .. lives)
if scene.death then
overlayPrint(2, 4, "That did not go well.")
elseif asking then
overlayPrint(2, 4, scene.prompt)
overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.")
end
end
local function waitForGo(text)
answer = nil
while answer ~= "go" do
overlayClear()
overlayPrint(2, 2, "Lives: " .. lives)
overlayPrint(2, 4, text)
singeYield()
end
end
local function playScene(name)
local scene = scenes[name]
discSkipToFrame(scene.first)
while discGetFrame() < scene.last do
local frame = discGetFrame()
local asking = scene.ask ~= nil and frame >= scene.ask
if not asking then
answer = nil
elseif answer == "left" or answer == "right" then
return scene[answer]
end
drawScene(scene, frame, asking)
singeYield()
end
discPause()
if scene.death then
lives = lives - 1
if lives == 0 then
ending = "The dragon wins. Game over."
return nil
end
waitForGo("Press the space bar to try again.")
end
return scene.goesTo
end
function onInputPressed(what)
if what == SWITCH_LEFT then
answer = "left"
elseif what == SWITCH_RIGHT then
answer = "right"
elseif what == SWITCH_BUTTON1 then
answer = "go"
end
end
function singeMain()
local scene = "arrive"
while scene ~= nil do
scene = playScene(scene)
end
waitForGo(ending .. " Space to quit.")
end
dofile("Singe/Framework.singe")
----
Ejecútalo:
----
Singe -R -v Singe/menuBackground.mkv movie/dragon.singe
----
Juega cuatro o cinco veces. Ve a la izquierda dos veces y escapa. Ve a la
derecha y deja que te coma. Quédate de brazos cruzados y que te coma igualmente.
Pierde las tres vidas.
[#_what_just_happened_12]
=== Qué acaba de pasar
[source,lua]
----
discSkipToFrame(scene.first)
while discGetFrame() < scene.last do
----
Una escena es un salto seguido de una espera, que es el patrón de la lección
quince con los números saliendo del registro en lugar de estar escritos a mano.
`discSkipToFrame` va a un fotograma y reproduce desde ahí, que es justo lo que es
un cambio de escena.
El número de fotograma es correcto en el instante en que vuelve la llamada, así
que el `while` nunca cree que sigue en la escena que acabas de dejar.
[source,lua]
----
local asking = scene.ask ~= nil and frame >= scene.ask
----
La única línea que está preparada para un campo que falta, y la razón por la que
la escena de muerte puede dejar fuera `ask`.
`and` significa que las dos mitades tienen que ser verdaderas, como en la lección
tres, y Lua comprueba primero la mitad izquierda. Si `scene.ask` es `nil`, la
mitad izquierda es falsa, Lua se para ahí y la comparación de la derecha no llega
a hacerse. Y menos mal, porque comparar `nil` con un número es un error, y te lo
encontrarás al final de esta lección.
`asking` es la respuesta a «¿está la pregunta en pantalla ahora mismo?»,
calculada una vez por fotograma y usada tres veces.
[source,lua]
----
if not asking then
answer = nil
elseif answer == "left" or answer == "right" then
return scene[answer]
end
----
Mientras la pregunta no está puesta, todo lo que pulse el jugador se tira. Tiene
que ser así, o un jugador que se apoye en la flecha izquierda durante la película
responderá a una pregunta que no ha leído.
Una vez que la pregunta está puesta, una respuesta termina la escena en el acto.
`return scene[answer]` merece una segunda mirada: `answer` guarda la cadena
`"left"` o `"right"`, y `scene["left"]` es lo mismo que `scene.left`. Poner el
nombre entre corchetes en lugar de después de un punto te deja buscar un campo
cuyo nombre tienes guardado en una variable. Dos líneas de código para las dos
respuestas, y con cinco respuestas seguirían siendo dos líneas.
El `elseif` también explica por qué solo se dejan pasar `"left"` y `"right"`. La
barra espaciadora pone `answer` a `"go"`, y `scene["go"]` es `nil`, así que
pulsar la barra espaciadora sin querer durante una pregunta terminaría el juego
en silencio. Di qué aceptas.
[source,lua]
----
discPause()
----
Justo después del bucle, y es fácil olvidarlo. La escena ha llegado a su último
fotograma, pero el disco no tiene ni idea de que la escena ha terminado -- sigue
reproduciendo de corrido lo que haya a continuación en el archivo, que en un
juego de verdad es la mitad de una escena de otro sitio completamente distinto.
Páralo en cuanto dejes de quererlo.
[source,lua]
----
if scene.death then
lives = lives - 1
----
`scene.death` es `nil` en todas las escenas salvo `pit`, y `nil` cuenta como
falso en un `if`, así que esto se lee como «si esta escena ha sido una muerte».
No tienes que escribir `death = false` en los otros tres registros, y no
deberías: que falte el campo lo dice igual de bien, y así hay un único sitio que
decide qué escenas son mortales.
El reintento son las dos líneas de debajo. Quita una vida y, si queda alguna,
quédate en el fotograma y espera a la barra espaciadora. Luego
`return scene.goesTo` manda al jugador de vuelta a `arrive`, y quien lo sabe es
el registro. Cambia `goesTo` a `"sunset"` y el reintento se convierte en un
punto de control en lugar de volver a empezar -- una palabra, y cambia la
dificultad del juego.
[source,lua]
----
overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.")
----
El temporizador. `scene.last - frame` es cuántos fotogramas quedan, y dividirlo
por los fotogramas de un segundo lo convierte en segundos. `math.ceil` redondea
un número hacia arriba hasta el siguiente entero, así que la cuenta atrás pone
`3`, `2`, `1` y nunca `0.4` ni `2.966666`.
`FPS` es `30` porque este vídeo va a algo más de treinta fotogramas por segundo.
Es el único número del script que es una suposición, y es una suposición con
nombre al principio del archivo en lugar de un `30` enterrado en mitad de una
línea, para que cuando cambies de vídeo haya exactamente una cosa que cambiar.
Fíjate en contra qué se mide el temporizador: la película. El jugador compite
contra el vídeo, no contra un reloj, así que la pregunta nunca puede durar más
que el plano al que pertenece. Esa es la razón por la que los juegos con
ramificaciones cuentan en fotogramas.
[#_why_not_write_it_out_by_hand]
=== Por qué no escribirlo a mano
Podrías escribir este juego como una sola función larga. Reproduce los
fotogramas 0 a 119; si izquierda, reproduce 120 a 259; si izquierda otra vez,
reproduce 340 a 410; si no, reproduce 260 a 339, quita una vida y vuelve arriba.
Con cuatro escenas sería más corto que lo que tienes.
Con cuarenta escenas sería un desastre, y vale la pena ser preciso sobre por qué.
Los números de fotograma estarían repartidos por todo el código, cada uno escrito
una vez y sin nombre, así que cambiar dónde empieza una escena significaría
encontrar cada sitio donde se menciona. La forma de la historia estaría enterrada
en la forma de las instrucciones `if`, así que nadie podría ver el juego sin
leerlo entero. Y añadir una escena significaría editar el código que reproduce,
que es el código que ya funciona -- cada escena nueva, otra ocasión de romper una
vieja.
La versión que has escrito tiene un trozo de código que reproduce escenas y un
trozo de datos que dice cuáles son las escenas. Añadir una escena solo toca los
datos. Eso vale la media hora de más que te ha costado, y es la misma idea que hay
debajo del archivo `games.dat` de la lección trece: describe la cosa, y luego
escribe un trozo de código que lea la descripción.
[#_try_it_16]
=== Pruébalo
. *Añade un final.* Dale a `escape` una pregunta y una segunda escena final que
reutilice fotogramas de algún punto de la película. Solo cambia la tabla.
. *Hazlo justo.* Baja `ask` en las dos escenas para que el jugador tenga cinco
segundos en lugar de tres, y luego súbelo para que tenga uno. Encuentra el
número que publicarías de verdad.
. *Mueve el punto de control.* Cambia el `goesTo` de `pit` a `"sunset"` y
juega. Luego piensa en qué pasa si un jugador muere en `arrive` -- y en si la
escena de muerte debería saber de dónde viene en lugar de que se lo digan.
. *Cuenta lo que ha visto el jugador.* Añade una tabla junto a `scenes` que
apunte qué nombres de escena se han reproducido, y muestra la cuenta en la
esquina. Ahora tienes un juego que puede decir «has encontrado tres de los
cinco finales».
. *Rompe el mapa.* Cambia el `left` de `arrive` a `"cave"`, una escena que no
existe. Ejecútalo, ve a la izquierda y lee lo que dice Lua. Ese error es el
más común que tiene un juego dirigido por datos, y conocer su forma te ahorra
una hora.
[#_break_it_on_purpose_15]
=== Rómpelo a propósito
Quita la guarda de la línea de `asking`, para que quede así:
[source,lua]
----
local asking = frame >= scene.ask
----
Ejecútalo y ve a la derecha. La escena `arrive` se reproduce bien, porque
`arrive` tiene un `ask`. Luego empieza la escena `pit`, que no lo tiene, y el
juego se para con esto:
----
Error executing function 'onOverlayUpdate': movie/dragon.singe:68: attempt to compare nil with number
stack traceback:
[C]: in function 'error'
Singe/Framework.singe:603: in function 'onOverlayUpdate'
----
Léelo como te enseñó la lección uno. El archivo y la línea van primero, y son
exactamente correctos: la línea 68 es la que acabas de cambiar. La queja es el
resto: le has preguntado a Lua si un número era mayor que `nil`, y Lua no tiene
ni idea. `scene.ask` es `nil` porque el registro `pit` no tiene campo `ask`, y un
campo que no está es `nil`.
Hay una parte de ese mensaje que merece una explicación, porque es una mentira
por omisión. La función que nombra es `onOverlayUpdate`, que tú no has escrito.
Es la del propio framework, la que según la lección quince se instala para mover
tu `singeMain`. El error ha salido a través de ella, así que ese es el nombre que
el motor tenía a mano, y la traza de la pila que hay debajo -- la lista de las
funciones que se estaban ejecutando, empezando por la más interna -- solo puede
mostrar al framework pasando tu error. No hagas caso a ninguno de los dos. El
archivo y la línea de arriba son tuyos, y son los que dicen la verdad.
[#_what_you_learned_16]
=== Lo que has aprendido
* Un juego con ramificaciones es un conjunto de escenas, cada una de las cuales
nombra las escenas que van después de ella.
* Guarda las escenas como datos -- una tabla de registros -- y el código que las
reproduce seguirá siendo una sola función por mucho que crezca el juego.
* Un registro guarda el nombre de la escena siguiente, no la escena, y el nombre
se busca cuando hace falta.
* Un campo que no está en un registro es `nil`, lo cual no es un error, y el
código que lee registros tiene que contar con ello.
* `scene[answer]` busca el campo cuyo nombre está en una variable;
`scene.left` y `scene["left"]` son el mismo campo.
* Pon el temporizador sobre el vídeo: una elección dura de un fotograma a otro,
así que nunca puede durar más que su propio plano.
* Tira todo lo que el jugador haya pulsado antes de que apareciera la pregunta.
* Pausa el disco en cuanto termine una escena, o seguirá reproduciendo las
imágenes de la escena siguiente.
* Un campo ausente que hace las veces de falso -- `death` en tres de los cuatro
registros -- mantiene la descripción corta y honesta.
* Comparar `nil` con un número es un error, y `and` es la forma de evitarlo.
[#_next_time_16]
=== La próxima vez
Tu juego pregunta izquierda o derecha. Las máquinas recreativas en las que creció
este tipo de juego preguntaban algo más difícil: apunta a la cosa de la pantalla
y dispárale. La lección diecisiete trata de las pistolas de luz -- adónde apunta
el jugador, qué hace el juego con eso y la calibración sin la que no puede vivir
una recreativa de verdad.