[#_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.