singe/docs/es/lessons/18-qte.adoc

616 lines
21 KiB
Text

[#_lesson_18_quick_time_events]
== Lección 18: Quick-time events
image::learn/18-qte.png[La lección terminada, 480]
Aparece una indicación. Tienes medio segundo. Pulsa lo correcto y la historia
sigue; pulsa lo que no es, o no pulses nada, y se va a otra parte.
Esa es toda la idea, y un número sorprendente de juegos de vídeo no están hechos
de casi nada más. Ya tienes todas las piezas: la lección catorce te dio los
fotogramas, la lección seis una lista de registros, la lección tres la entrada,
y la lección dieciséis las ramificaciones. Esta lección las junta y añade una
cosa que es nueva de verdad -- una ventana de tiempo, y lo que pasa cuando se
cierra.
[#_the_clock_is_the_film]
=== El reloj es la película
Aquí todo se mide en *fotogramas*, no en segundos, y vale la pena detenerse en
ello, porque es la única decisión que hace que esto funcione.
La lección catorce decía que un fotograma es la dirección de un momento de la
película. La indicación tiene que aparecer cuando el actor empieza a lanzar el
golpe, y la ventana tiene que cerrarse cuando llega el puño. Eso no son tiempos,
son lugares de las imágenes, y los encontraste avanzando por ellas paso a paso.
Si escribes la ventana en segundos, tienes que poner en marcha un cronómetro
cuando aparece la indicación, y entonces los dos relojes -- el tuyo y el de la
película -- pueden desfasarse. Si el vídeo da tirones en un ordenador lento, tu
cronómetro sigue corriendo y el jugador sale castigado por culpa de su hardware.
Los fotogramas no pueden desfasarse, porque solo hay un reloj. `discGetFrame()`
es la hora, siempre. Una ventana son dos números de fotograma, y «cuánto
queda» es una resta.
Ejecútalo con el mismo vídeo que antes:
----
Singe -R -v Singe/menuBackground.mkv moves
----
Sigue siendo la cuadrícula morada y la puesta de sol. Imagínate una pelea.
[#_a_move_is_a_record]
=== Un movimiento es un registro
Esta es la tabla de la que cuelga toda la lección:
[source,lua]
----
local moves = {
{ first = 60, last = 105, switch = SWITCH_LEFT, name = "LEFT" },
{ first = 140, last = 185, switch = SWITCH_UP, name = "UP" },
{ first = 220, last = 265, switch = SWITCH_RIGHT, name = "RIGHT" },
{ first = 300, last = 345, switch = SWITCH_DOWN, name = "DOWN" },
}
----
Cuatro movimientos. Cada uno dice cuándo se abre la ventana, cuándo se cierra,
qué interruptor la responde y qué poner en pantalla. Cuarenta y cinco fotogramas
son más o menos un segundo y medio a la velocidad de este vídeo, lo cual es
generoso; los juegos de verdad suelen darte menos de la mitad.
Todo lo que hace que un movimiento sea un movimiento está en esa tabla. No hay
ningún código más abajo que sepa que hay cuatro movimientos, ni que el segundo es
hacia arriba, ni que la primera ventana se abre en el fotograma sesenta. Añadir
un quinto movimiento es añadir una línea. Cambiar el ritmo de todos los
movimientos es editar números. Nadie tiene que leer una función para cambiar la
pelea.
Eso no es un truco para esta lección. Es la diferencia entre un juego que puedes
ajustar y uno que no, y no cuesta nada hacerlo desde el principio. Cuando te veas
a punto de escribir `if frame == 60 then` por cuarta vez, para y haz una tabla.
[#_what_happens_when]
=== Qué pasa y cuándo
A un movimiento le pueden pasar tres cosas, y son tres resultados distintos, no
dos:
* El jugador pulsa el interruptor correcto dentro de la ventana. Se puntúa y se
sigue.
* El jugador pulsa un interruptor *equivocado* dentro de la ventana. Lo ha
intentado y ha fallado.
* La ventana se cierra sin ninguna respuesta. Se ha quedado helado.
El código perezoso trata igual los dos últimos. Los juegos casi nunca lo hacen,
porque al jugar se sienten completamente distintos: equivocarse es un error, y
quedarse helado es otro error distinto, y normalmente la película va a un sitio
distinto con cada uno. Aquí la respuesta equivocada manda el disco al fotograma
`362` y la falta de respuesta lo manda al fotograma `388`, lo que en estas
imágenes significa dos cantidades ligeramente distintas de puesta de sol. En
imágenes de verdad serían dos planos distintos de ti perdiendo.
Así que cada registro gana un quinto campo mientras dura la partida -- se llama
`answer` y es `nil` hasta que al movimiento le pasa algo, y luego una de tres
cadenas:
[source,lua]
----
move.answer = "right"
move.answer = "wrong"
move.answer = "late"
----
`nil` es el valor que te da Lua para un campo al que nadie ha dado valor. Lo
conociste en la lección tres. Aquí se gana el sueldo: «todavía sin respuesta» es
de verdad un estado distinto de cualquiera de las tres respuestas, y `nil` lo
dice sin que tengas que inventarte una palabra para ello.
[#_finding_the_move_that_is_being_asked]
=== Encontrar el movimiento que se está pidiendo
Todo lo demás necesita saber una cosa: ¿qué movimiento está abierto ahora mismo,
si es que hay alguno?
[source,lua]
----
local function moveAt(frame)
for _, move in ipairs(moves) do
if move.answer == nil and frame >= move.first and frame <= move.last then
return move
end
end
return nil
end
----
Recorre la lista y responde con el primer movimiento cuya ventana cubra este
fotograma y que no se haya respondido. Si no coincide ninguno, responde `nil`.
Vale la pena fijarse en que se devuelve `nil` a propósito. Una función que a
veces no tiene respuesta debería decirlo, y «no hay ningún movimiento abierto» es
una respuesta perfectamente buena. Quien la llama escribe entonces
`if move ~= nil then`, y el código que dibuja la indicación nunca se ejecuta
cuando no hay nada que indicar.
La comprobación `move.answer == nil` es lo que impide que un movimiento se pida
dos veces. En cuanto el jugador acierta, la indicación desaparece aunque la
ventana siga abierta -- que es justo lo que debe hacer, porque ya ha respondido.
[#_reading_the_answer]
=== Leer la respuesta
[source,lua]
----
function onInputPressed(what)
if not isDirection(what) then
return
end
local move = moveAt(discGetFrame())
if move == nil then
return
end
if what == move.switch then
move.answer = "right"
score = score + RIGHT_SCORE
else
move.answer = "wrong"
endRun("WRONG WAY", WRONG_FRAME)
end
end
----
Léelo de arriba abajo. Si el interruptor no es una de las cuatro direcciones, no
es asunto nuestro: vete. Si no hay ningún movimiento abierto, pulsar una
dirección no significa nada: vete. Si no, hay exactamente un movimiento con el
que comparar, y una comparación que hacer.
Esas dos líneas `return` del principio son guardas: echan fuera los casos que no
te interesan para que el código de debajo solo se ejecute en la única situación
para la que se escribió. Escribirlo al revés, con el trabajo de verdad metido
tres niveles más adentro, dentro de tres bloques `if`, dice lo mismo y es mucho
más difícil de leer.
`isDirection` es una función diminuta que no hace nada más que mantener corta
esa primera línea:
[source,lua]
----
local function isDirection(what)
return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT
end
----
Fíjate en que devuelve la propia comparación en lugar de decir
`if ... then return true else return false end`. Una comparación ya es `true` o
`false`. Comprobar si algo es verdad para poder decir que es verdad es una
costumbre que conviene perder pronto.
[#_the_window_closing]
=== La ventana se cierra
Nadie pulsa nada, y los fotogramas siguen pasando. Algo tiene que darse cuenta.
Ese algo es `onOverlayUpdate`, porque se ejecuta en cada fotograma, haga algo el
jugador o no:
[source,lua]
----
if not runOver then
for _, move in ipairs(moves) do
if move.answer == nil and frame > move.last then
move.answer = "late"
endRun("TOO SLOW", LATE_FRAME)
break
end
end
end
----
Un movimiento sin responder cuyo último fotograma ya ha pasado se ha perdido.
`break` sale del bucle inmediatamente, porque una vez terminada la partida no
tiene sentido mirar el resto.
`endRun` es la ramificación, y es la misma ramificación que escribiste en la
lección dieciséis:
[source,lua]
----
local function endRun(reason, frame)
verdict = reason
runOver = true
discSkipToFrame(frame)
end
----
Tres líneas. Recuerda qué decir, recuerda que la partida ha terminado y manda la
película a otra parte. Un quick-time event perdido no es un mecanismo especial.
Es una elección que el jugador ha hecho al no hacer ninguna, y la película se
ramifica con ella exactamente igual que con una puerta que hubiera escogido.
[#_a_gauge_that_drains]
=== Un indicador que se vacía
El jugador no ve los números de fotograma. Necesita ver que se acaba el tiempo, y
necesita verlo sin apartar la vista de la acción, lo que significa una forma que
cambia en lugar de un número que cuenta.
Dos números te lo dan todo:
[source,lua]
----
local left = move.last - frame
local span = move.last - move.first
----
`span` es lo que dura la ventana. `left` es lo que queda de ella. La barra ocupa
`left` partido por `span` del ancho completo:
[source,lua]
----
fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H)
----
`//` es una división que tira los decimales, que es lo que quieres aquí, porque
una barra de ciento treinta y tres píxeles y un tercio de ancho no existe.
Multiplica antes de dividir -- `GAUGE_W * left // span` y no
`GAUGE_W * (left // span)` -- o los decimales se tiran mientras todavía son la
única información que tienes, y la barra salta de llena a vacía sin nada en
medio.
El overlay no tiene rectángulos rellenos. `overlayBox` dibuja un contorno, que es
lo que quieres para el marco del indicador, y el relleno lo pones tú:
[source,lua]
----
local function fillBar(x, y, width, height)
if width < 1 then
return
end
for row = 0, height - 1 do
overlayLine(x, y + row, x + width - 1, y + row)
end
end
----
Dieciocho líneas horizontales apiladas una encima de otra. La guarda del
principio importa: en el último fotograma de la ventana `left` es cero, el ancho
es cero, y sin ella `x + width - 1` queda un píxel a la *izquierda* de `x` y
dibujas una línea corta hacia atrás en lugar de nada.
El último toque es un cambio de color cuando la cosa se pone desesperada:
[source,lua]
----
if left * 3 < span then
colorForeground(255, 60, 60, 255)
else
colorForeground(255, 200, 0, 255)
end
----
La barra se pone roja en su último tercio. Escrito así -- multiplicando el tiempo
que queda en lugar de dividir lo que dura la ventana -- todo sigue siendo números
enteros y funciona con una ventana de cualquier longitud.
[#_the_whole_thing_10]
=== Todo junto
[source,lua]
----
-- Aprende a programar con Singe -- Lección 18: Quick-time events
dofile("Singe/Framework.singe")
overlaySetResolution(discGetWidth(), discGetHeight())
local FIRST_FRAME = 20
local WRONG_FRAME = 362
local LATE_FRAME = 388
local LAST_FRAME = 415
local PROMPT_COL = 54
local PROMPT_ROW = 22
local GAUGE_X = 210
local GAUGE_Y = 330
local GAUGE_W = 300
local GAUGE_H = 18
local RIGHT_SCORE = 100
local moves = {
{ first = 60, last = 105, switch = SWITCH_LEFT, name = "LEFT" },
{ first = 140, last = 185, switch = SWITCH_UP, name = "UP" },
{ first = 220, last = 265, switch = SWITCH_RIGHT, name = "RIGHT" },
{ first = 300, last = 345, switch = SWITCH_DOWN, name = "DOWN" },
}
local score = 0
local runOver = false
local verdict = ""
local function fillBar(x, y, width, height)
if width < 1 then
return
end
for row = 0, height - 1 do
overlayLine(x, y + row, x + width - 1, y + row)
end
end
local function isDirection(what)
return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT
end
local function moveAt(frame)
for _, move in ipairs(moves) do
if move.answer == nil and frame >= move.first and frame <= move.last then
return move
end
end
return nil
end
local function startRun()
score = 0
runOver = false
verdict = ""
for _, move in ipairs(moves) do
move.answer = nil
end
discSkipToFrame(FIRST_FRAME)
end
local function endRun(reason, frame)
verdict = reason
runOver = true
discSkipToFrame(frame)
end
function onInputPressed(what)
if runOver then
if what == SWITCH_START1 then
startRun()
end
return
end
if not isDirection(what) then
return
end
local move = moveAt(discGetFrame())
if move == nil then
return
end
if what == move.switch then
move.answer = "right"
score = score + RIGHT_SCORE
else
move.answer = "wrong"
endRun("WRONG WAY", WRONG_FRAME)
end
end
function onOverlayUpdate()
local frame = discGetFrame()
if not runOver then
for _, move in ipairs(moves) do
if move.answer == nil and frame > move.last then
move.answer = "late"
endRun("TOO SLOW", LATE_FRAME)
break
end
end
end
if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then
if not runOver then
verdict = "CLEAR"
runOver = true
end
discSearch(LAST_FRAME)
end
overlayClear()
local move = nil
if not runOver then
move = moveAt(frame)
end
if move ~= nil then
local left = move.last - frame
local span = move.last - move.first
overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name)
colorForeground(255, 255, 255, 255)
overlayBox(GAUGE_X - 2, GAUGE_Y - 2, GAUGE_X + GAUGE_W + 1, GAUGE_Y + GAUGE_H + 1)
if left * 3 < span then
colorForeground(255, 60, 60, 255)
else
colorForeground(255, 200, 0, 255)
end
fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H)
end
overlayPrint(2, 1, "SCORE " .. score .. " FRAME " .. frame)
if runOver then
overlayPrint(2, 3, verdict)
overlayPrint(2, 4, "PRESS 1 TO RUN IT AGAIN")
end
return OVERLAY_UPDATED
end
startRun()
----
Las teclas de flecha responden a las indicaciones. Acierta las cuatro y la
película llega hasta el final y se queda ahí con `CLEAR`. Falla una, o quédate de
brazos cruzados, y corta a otro fotograma con otra palabra en pantalla. `1` la
vuelve a poner en marcha.
[#_what_just_happened_14]
=== Qué acaba de pasar
Casi todo ya lo has leído. Hay cuatro trozos que merecen una segunda mirada.
[source,lua]
----
if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then
----
Hay que atrapar el final de la película, o se sale del final y no queda nada que
ver. `discSearch` va a un fotograma y se *pausa* ahí, que es exactamente lo que
quiere un plano final. La mitad de `discGetState()` impide que eso vuelva a pasar
en cada uno de los fotogramas siguientes: una vez en pausa, el disco ya no está
en `DISC_PLAYING`, así que la condición es falsa y el salto no se repite sesenta
veces por segundo. `discGetState` y las constantes `DISC_*` están en la
referencia, en `discGetState`.
[source,lua]
----
local move = nil
if not runOver then
move = moveAt(frame)
end
----
`onOverlayUpdate` vuelve a preguntar cuál es el movimiento abierto, en lugar de
que se lo diga `onInputPressed`. Es a propósito. Hay exactamente una función que
sabe qué movimiento está abierto, y todo el mundo se lo pregunta a ella. Si la
indicación saliera de una variable que el código de entrada mantuviera al día,
habría dos trozos de código recordando el mismo dato, y tarde o temprano uno de
ellos se equivocaría. Cuando tengas que elegir entre preguntar y recordar,
pregunta.
[source,lua]
----
overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name)
----
`overlayPrint` cuenta en celdas de carácter, como desde la lección uno, así que
la indicación se coloca a ojo: la columna cincuenta y cuatro está lo bastante
cerca del centro para palabras de esta longitud. Está lo bastante cerca, no bien,
y «PRESS RIGHT» termina tres caracteres más a la derecha que «PRESS UP». La lección
diecinueve carga una fuente de verdad, y con ella llega la posibilidad de medir
una cadena en píxeles y centrarla como es debido.
[source,lua]
----
function onInputPressed(what)
if runOver then
if what == SWITCH_START1 then
startRun()
end
return
end
----
Un callback, dos trabajos completamente distintos, y `runOver` elige cuál.
Mientras dura la partida, las direcciones son movimientos; una vez terminada, la
única tecla que significa algo es la de empezar. Una variable que decide qué
reglas se aplican es un *estado*, que es como lo llamó la lección doce, y un
juego es sobre todo un puñado de ellos.
[#_try_it_18]
=== Pruébalo
. *Hazlo más difícil.* Cambia el `last` del primer movimiento de `105` a `75`.
Quince fotogramas son medio segundo. Juégalo y averigua si puedes.
. *Añade un movimiento.* Pon una quinta línea en la tabla con una ventana entre
los fotogramas `370` y `400`, y sube `LAST_FRAME` a `418`. No deberías tener
que tocar ninguna otra línea. Si tienes que hacerlo, algo del código sabe
demasiado de la tabla.
. *Puntúa la rapidez.* Da más puntos por responder pronto. En `onInputPressed`,
donde se puntúa la respuesta correcta, calcula `left` y `span` a partir de
`move` y `discGetFrame()` igual que hace el indicador, y suma
`RIGHT_SCORE * left // span` en lugar de `RIGHT_SCORE`. Decide si eso hace el
juego mejor o solo más ajetreado.
. *Dale a una respuesta equivocada su propia consecuencia.* En lugar de terminar
la partida, haz que pulsar mal cueste cincuenta puntos y deje la ventana
abierta para volver a intentarlo. Tendrás que dejar de poner `move.answer`, y
te encontrarás con la razón por la que se ponía.
. *Dos indicaciones a la vez.* Solapa las ventanas de dos movimientos y mira qué
pasa. `moveAt` devuelve la primera coincidencia, así que solo se pide uno.
Decide qué querrías, y qué haría falta.
[#_break_it_on_purpose_17]
=== Rómpelo a propósito
Borra el `break` del bucle que encuentra los movimientos perdidos, y pierde el
primero a propósito. La partida termina, el disco salta y todo parece correcto.
Ahora pon `debugPrint(move.name .. " missed")` junto a `move.answer = "late"` y
vuelve a hacerlo. La consola muestra una línea, como era de esperar.
Luego quita también el `if not runOver then` que rodea el bucle, y vuelve a
perder el primer movimiento:
----
LEFT missed
UP missed
RIGHT missed
DOWN missed
----
La primera línea llega cuando se cierra la ventana, como antes. Las otras tres
llegan juntas en el fotograma siguiente, en el instante en que el disco aterriza
en el 388: todas las ventanas que quedaban están ahora en el pasado, así que
todos los movimientos sin responder se pierden a la vez, y cada uno llama a
`endRun` y vuelve a saltar. El juego sigue funcionando más o menos, y eso es lo
que lo hace peligroso. Tres saltos de más en un solo fotograma, tres veces
sobrescrito el veredicto, y nada en pantalla lo dice.
Aquí tampoco hay mensaje de error. La lección es que ese `break` y esa guarda no
son por limpieza. Son las dos líneas que dicen «esto ya está decidido», y el
código que sigue decidiendo algo que ya ha decidido es una de las fuentes de
fallos más fiables que vas a escribir nunca.
[#_what_you_learned_18]
=== Lo que has aprendido
* Un quick-time event es una indicación, una ventana y una consecuencia.
* Mide la ventana en fotogramas, porque la película es el reloj y solo debe haber
un reloj.
* Guarda los movimientos en una tabla de registros, para que añadir un movimiento
sea añadir una línea.
* Acertar, fallar y no responder son tres resultados, y normalmente llevan a tres
sitios distintos.
* `nil` en un campo de un registro es un estado de verdad: nadie ha respondido
todavía.
* Una sola función responde a «qué movimiento está abierto», y todo el mundo se
lo pregunta en lugar de guardar una copia.
* Las cláusulas de guarda -- un `return` temprano para los casos que no te
interesan -- evitan que el trabajo de verdad quede anidado.
* `//` divide y tira los decimales; multiplica antes de dividir.
* `overlayBox` dibuja un contorno, así que una barra rellena es una pila de
llamadas a `overlayLine`.
* Un movimiento perdido es una ramificación, y `discSkipToFrame` la toma,
exactamente como en la lección dieciséis.
[#_next_time_18]
=== La próxima vez
La tercera parte está terminada. Puedes reproducir película, esperarla,
ramificarla, apuntarle y medir el tiempo del jugador contra ella. Lo que has
estado escribiendo encima de todo eso es la fuente de consola del motor, que
tiene seis píxeles de ancho y está pensada para diagnósticos. La lección
diecinueve carga un tipo de letra de verdad, lo mide y hace que las palabras de
la pantalla parezcan parte del juego.