540 lines
20 KiB
Text
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.
|