[#_lesson_17_light_guns] == Lección 17: Pistolas de luz image::learn/17-light-guns.png[La lección terminada, 480] Vas a hacer una galería de tiro. Aparecen dianas sobre el vídeo en los fotogramas que tú eliges, apuntas a la pantalla y aprietas un gatillo, y las que aciertas puntúan. No necesitas una pistola de luz para escribir esto. Necesitas un ratón, porque para Singe una pistola de luz *es* un ratón: informa de una posición en las mismas coordenadas, con las mismas llamadas, y su gatillo llega como el mismo interruptor que un botón del ratón. Escribe el juego para el ratón de tu mesa y funcionará en una recreativa con una pistola atornillada, que es la única forma sensata de hacerlo. Una pistola no es otro tipo de entrada. Es un ratón que alguien apunta. [#_what_you_are_pointing_at] === A qué apuntas Empieza igual que en la lección catorce, con el vídeo que el motor ha desempaquetado para ti: ---- Singe -R -v Singe/menuBackground.mkv gallery ---- Esas imágenes son una cuadrícula morada y una puesta de sol. No son un atraco a un banco. Todos los números de fotograma de esta lección se refieren a ellas para que el código funcione el mismo día que lo escribes, y el clip entero mide 720 por 480 y dura unos 420 fotogramas, que a unos treinta fotogramas por segundo son catorce segundos. Imagínate algo mejor. Al código le da igual lo que haya en la imagen. [#_match_the_overlay_to_the_video] === Ajusta el overlay al vídeo Pon esto al principio de `gallery.singe`, debajo de la línea del `dofile`: [source,lua] ---- overlaySetResolution(discGetWidth(), discGetHeight()) ---- Sin esa llamada, el overlay mide la *mitad* que el vídeo en cada dirección -- aquí, 360 por 240 -- y cada coordenada con la que dibujas, y cada posición del ratón que te llega, está en ese espacio más pequeño. El overlay pequeño no tiene nada de malo, pero es una conversión más que tener en la cabeza mientras averiguas dónde ha caído un disparo. Ajustarlo al vídeo significa que una diana en `x = 80` está a ochenta píxeles del borde de la imagen, y un disparo en `x = 80` también. La entrada de la referencia de `overlaySetResolution` explica lo que cuesta. [#_where_the_gun_is_pointing] === Adónde apunta la pistola Eso lo responde una sola llamada: [source,lua] ---- local x, y = mouseGetPosition(0) ---- `mouseGetPosition` te devuelve dos números, algo que todavía no habías visto hacer a una función y con lo que Lua está encantado: una función puede devolver tantos valores como quiera, y tú los recoges poniendo a la izquierda del `=` otros tantos nombres. El `0` dice *por qué* dispositivo apuntador preguntas, contando desde cero. Con un ratón, o con una pistola, la respuesta es siempre el dispositivo `0`. Los números están en coordenadas del overlay, no en píxeles del escritorio, y por eso valía la pena hacer la llamada de antes. La posición es la última de la que informó el dispositivo, así que puedes pedirla cuando quieras y tantas veces como quieras. Pídela en `onOverlayUpdate` y dibuja con la respuesta. Hay otra forma de obtener los mismos números. `onMouseMoved` es un callback, como `onOverlayUpdate`, y el motor lo llama cada vez que se mueve un puntero, y le pasa la posición nueva. Es la elección correcta cuando quieres *reaccionar* al movimiento. Para dibujar un punto de mira no quieres eso: quieres saber dónde está la cosa ahora mismo, y `mouseGetPosition` te lo dice sin que tengas que guardar una copia. [#_drawing_a_crosshair] === Dibujar un punto de mira El motor no te lo va a dibujar. Un punto de mira son cuatro líneas y un círculo: [source,lua] ---- local function drawCrosshair(x, y) colorForeground(255, 255, 255, 255) overlayCircle(x, y, 10) overlayLine(x - 18, y, x - 4, y) overlayLine(x + 4, y, x + 18, y) overlayLine(x, y - 18, x, y - 4) overlayLine(x, y + 4, x, y + 18) end ---- Los huecos importan más que las líneas. Dejar los cuatro radios sin llegar al círculo te da un agujero en el centro por el que ver la diana, que es todo el sentido de un punto de mira y lo primero que los principiantes tapan al dibujar. Una cosa que deberías preguntar antes de dibujarlo: [source,lua] ---- local drawAim = singeWantsCrosshairs() ---- Eso devuelve `false` cuando el jugador arrancó Singe con `--nocrosshair`. Algunas pistolas de luz ponen un punto propio en la pantalla, y dos puntos de mira a unos pocos píxeles de distancia son peor que ninguno. La respuesta no puede cambiar mientras el juego está en marcha, así que léela una vez al principio y guárdala en una variable. No tienes que ocultar el puntero del sistema, porque Singe ya lo ha hecho. Captura el ratón al arrancar, lo que lo encierra en la ventana y oculta la flecha del escritorio. Si alguna vez lo quieres de vuelta -- para un editor de niveles, por ejemplo -- `mouseSetCaptured(false)` lo suelta, y la tecla asignada a `INPUT_GRAB`, que es la `G` en los controles que vienen con Singe, activa y desactiva lo mismo mientras juegas. [#_the_trigger] === El gatillo Un gatillo es un botón, y los botones llegan a `onInputPressed`, que viste por primera vez en la lección tres: [source,lua] ---- function onInputPressed(what, device) if what ~= SWITCH_BUTTON3 then return end local x, y = mouseGetPosition(device or 0) -- ... end ---- `SWITCH_BUTTON3` es el botón izquierdo del ratón, y en una pistola es el gatillo. El `controls.cfg` que viene con Singe asigna el botón izquierdo a `SWITCH_BUTTON3`, el derecho a `SWITCH_BUTTON1` y el central a `SWITCH_BUTTON2`. Esos números parecen barajados porque lo están: los interruptores son los botones del panel de la recreativa, y qué control físico va cableado a cuál es asunto de la recreativa, no del juego. La Mayús izquierda también está asignada a `SWITCH_BUTTON3`, así que puedes disparar sin ratón mientras haces pruebas. El segundo argumento es lo nuevo. Cuando el interruptor viene de un ratón o de una pistola de luz, `device` es el índice de ese dispositivo -- `0` para el primero, `1` para el segundo, y así sucesivamente. Cuando viene de una tecla o de un mando es `nil`, porque no ha intervenido ningún dispositivo apuntador. Por eso la llamada de arriba dice `device or 0`: si `device` es `nil`, usa `0` en su lugar. Usarás `or` así constantemente, y se lee exactamente como suena -- «device, o nada, y en ese caso cero». Ese argumento de más es lo que hace posible una recreativa de dos pistolas. La pistola del jugador uno es el dispositivo `0` y la del jugador dos es el dispositivo `1`, y el mismo `onInputPressed` las distingue sin que tengas que adivinar. [#_did_it_hit_anything] === ¿Ha dado en algo? Ya sabes responder a esto. Es la lección once: [source,lua] ---- if collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then ---- Un disparo es un punto. Una diana es un rectángulo. `collidePointRect` pregunta si el punto está dentro del rectángulo, y el borde cuenta como dentro. Disparar no tiene nada de especial. La comprobación de impacto de un juego de pistolas es la misma que usa un juego de plataformas para saber si has caído sobre un bloque, y la familia `collide` de la referencia tiene el resto de las formas. [#_targets_that_come_and_go] === Dianas que vienen y van Lo único que cambia el vídeo es *cuándo* existe una diana. En la lección once, una roca estaba ahí hasta que le disparabas. Aquí una diana está en pantalla mientras la película muestra la parte de la escena a la que pertenece, y después desaparece tanto si le has disparado como si no. Eso son dos campos más en el registro, y la lección seis te dio la herramienta: [source,lua] ---- local targets = { { first = 40, last = 110, x = 80, y = 110 }, { first = 120, last = 190, x = 470, y = 140 }, { first = 200, last = 270, x = 290, y = 250 }, { first = 280, last = 360, x = 150, y = 300 }, } ---- Cuatro registros, cada uno con el fotograma en el que aparece la diana, el fotograma en el que se va y dónde está. Añadir una quinta diana es añadir una línea. Esa es la forma a la que tienes que recurrir cada vez que te veas a punto de escribir las mismas cuatro líneas de código con números distintos. Si una diana cuenta o no es entonces una pequeña función propia: [source,lua] ---- local function targetIsUp(target, frame) return not target.hit and frame >= target.first and frame <= target.last end ---- El dibujo se lo pregunta, y el disparo también, y para eso está en un solo sitio: una diana que puedes ver y una diana a la que puedes acertar nunca pueden estar en desacuerdo. [#_the_whole_thing_9] === Todo junto [source,lua] ---- -- Aprende a programar con Singe -- Lección 17: Pistolas de luz dofile("Singe/Framework.singe") overlaySetResolution(discGetWidth(), discGetHeight()) local FIRST_FRAME = 20 local LAST_FRAME = 400 local TARGET_SIZE = 96 local HIT_SCORE = 100 local FLASH_TIME = 12 local targets = { { first = 40, last = 110, x = 80, y = 110 }, { first = 120, last = 190, x = 470, y = 140 }, { first = 200, last = 270, x = 290, y = 250 }, { first = 280, last = 360, x = 150, y = 300 }, } local aimX = overlayGetWidth() / 2 local aimY = overlayGetHeight() / 2 local score = 0 local shots = 0 local hits = 0 local flash = 0 local flashX = 0 local flashY = 0 local drawAim = singeWantsCrosshairs() local function drawCrosshair(x, y) colorForeground(255, 255, 255, 255) overlayCircle(x, y, 10) overlayLine(x - 18, y, x - 4, y) overlayLine(x + 4, y, x + 18, y) overlayLine(x, y - 18, x, y - 4) overlayLine(x, y + 4, x, y + 18) end local function targetIsUp(target, frame) return not target.hit and frame >= target.first and frame <= target.last end local function startRound() score = 0 shots = 0 hits = 0 flash = 0 for _, target in ipairs(targets) do target.hit = false end discSkipToFrame(FIRST_FRAME) end function onInputPressed(what, device) if what == SWITCH_START1 then startRound() return end if what ~= SWITCH_BUTTON3 then return end local x, y = mouseGetPosition(device or 0) local frame = discGetFrame() shots = shots + 1 for _, target in ipairs(targets) do if targetIsUp(target, frame) and collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then target.hit = true hits = hits + 1 score = score + HIT_SCORE flash = FLASH_TIME flashX = x flashY = y return end end end function onOverlayUpdate() local frame = discGetFrame() if frame >= LAST_FRAME then startRound() frame = FIRST_FRAME end aimX, aimY = mouseGetPosition(0) overlayClear() for _, target in ipairs(targets) do if targetIsUp(target, frame) then colorForeground(255, 200, 0, 255) overlayBox(target.x, target.y, target.x + TARGET_SIZE - 1, target.y + TARGET_SIZE - 1) overlayCircle(target.x + TARGET_SIZE / 2, target.y + TARGET_SIZE / 2, TARGET_SIZE / 3) end end if flash > 0 then colorForeground(255, 255, 255, 255) overlayCircle(flashX, flashY, 30 - flash * 2) flash = flash - 1 end overlayPrint(2, 1, "SCORE " .. score .. " HITS " .. hits .. "/" .. shots .. " FRAME " .. frame) if drawAim then drawCrosshair(aimX, aimY) end return OVERLAY_UPDATED end startRound() ---- Apunta a una caja amarilla y haz clic. El contador de la esquina sube, se abre un anillo blanco donde has acertado y la caja desaparece. Si fallas, solo sube la cuenta de disparos, que es el marcador más barato que existe y le dice al jugador más sobre cómo lo está haciendo que la puntuación. La ronda vuelve a empezar sola cuando se acaba la película, y `1` la reinicia cuando quieras. [#_what_just_happened_13] === Qué acaba de pasar [source,lua] ---- local frame = discGetFrame() if frame >= LAST_FRAME then startRound() frame = FIRST_FRAME end ---- El vídeo es el reloj, como en la lección catorce, y `discGetFrame` es la forma de leerlo. Todo lo demás del fotograma se decide a partir de ese único número. Cuando la película pasa del final de la última diana, la ronda vuelve a empezar: `startRound` pone a cero la puntuación, marca todas las dianas como no disparadas y manda el disco de vuelta al principio con `discSkipToFrame`. La línea de debajo cambia también la copia local de `frame`, porque se leyó antes del salto y todavía guarda el número viejo. Sin ella, el dibujo de más abajo pasaría un fotograma trabajando con un número que ya no es verdad. [source,lua] ---- shots = shots + 1 for _, target in ipairs(targets) do if targetIsUp(target, frame) and collidePointRect(...) then ---- Cuenta el disparo antes de buscar un impacto, para que un fallo también cuente. Luego recorre la lista. El `return` de dentro del bucle se para en la primera diana acertada, lo que significa que una bala no puede llevarse dos dianas que se solapan -- una regla que tienes que decidir en un sentido o en otro, y esta es la línea que la decide. [source,lua] ---- overlayBox(target.x, target.y, target.x + TARGET_SIZE - 1, target.y + TARGET_SIZE - 1) ---- `overlayBox` quiere dos esquinas opuestas, no una esquina y un tamaño, así que la esquina lejana es la cercana más el tamaño. El `- 1` está porque las dos esquinas se incluyen: una caja de `80` a `175` mide noventa y seis píxeles de ancho. `collidePointRect`, en cambio, quiere una esquina y un tamaño, y por eso `TARGET_SIZE` entra solo allí y sumado a la esquina aquí. Confundir esas dos cosas es un fallo muy común de verdad, y se nota en dianas a las que puedes acertar un poco fuera de donde están dibujadas. [source,lua] ---- if flash > 0 then colorForeground(255, 255, 255, 255) overlayCircle(flashX, flashY, 30 - flash * 2) flash = flash - 1 end ---- `flash` es una cuenta atrás en fotogramas, que se pone a doce cuando un disparo acierta y baja en uno cada vez que se dibuja la pantalla. Como el radio se calcula *a partir de* la cuenta atrás, el anillo crece a medida que el número encoge. Doce fotogramas son menos de medio segundo. Una respuesta que dura más que eso deja de sentirse como consecuencia del gatillo y empieza a parecer el tiempo que hace. [#_two_guns] === Dos pistolas Estás a dos cambios de una recreativa para dos jugadores, y ninguno de ellos está en el bucle del juego. El primero es `mouseSetMode(MOUSE_MANY)`, que le dice a Singe que lea cada dispositivo por separado en lugar de juntarlos todos en un solo cursor. `mouseHowMany()` dice cuántos encontró al arrancar, así que lo normal es que un juego pregunte antes de comprometerse: [source,lua] ---- if mouseHowMany() >= 2 then mouseSetMode(MOUSE_MANY) end ---- A partir de ahí, `mouseGetPosition(1)` es la segunda pistola, y el argumento `device` de `onInputPressed` dice qué pistola ha disparado. Ten dos puntos de mira, dos puntuaciones y una sola lista de dianas, y tendrás un juego cooperativo. El segundo cambio no está en tu script en absoluto. El `controls.cfg` que viene con Singe solo asigna los botones del *primer* ratón, así que el gatillo de la segunda pistola no llega a ninguna parte. Una recreativa con dos pistolas necesita su propia línea en ese archivo que nombre las dos: [source,lua] ---- INPUT_ACTION_3 = { SCANCODE.LSHIFT, GAMEPAD_0.BUTTON_X, MOUSE_0.BUTTON_LEFT, MOUSE_1.BUTTON_LEFT } ---- Dilo en tus instrucciones. Un jugador cuya segunda pistola no hace nada le echará la culpa a tu juego, y tendrá media razón. [#_the_other_way_to_test_a_hit] === La otra forma de comprobar un impacto Hay una segunda forma de responder a «¿ha dado ese disparo en algo?», y vale la pena conocerla aunque hoy no la vayas a usar. `vldpGetPixel(x, y)` lee el color de un píxel del fotograma de vídeo que está en pantalla. Los juegos lo usan desde la primerísima versión de Singe: pinta cada diana de las imágenes de un color que no aparezca en ningún otro sitio, y cuando se apriete el gatillo pregunta qué color hay debajo del punto de mira. Te da formas de impacto que siguen la acción con exactitud, gratis, sin rectángulos que preparar. Te cuesta unas imágenes que tienes que preparar así, y una comprobación de impacto que no puedes ver ni depurar. La entrada de la referencia de `vldpGetPixel` tiene los detalles. Los rectángulos son lo correcto por defecto; guárdate esto en el bolsillo. [#_what_only_a_cabinet_cares_about] === Lo que solo le importa a una recreativa Todo lo anterior funciona en tu mesa. Varias cosas no existen en absoluto en tu mesa, y este es un buen momento para nombrarlas y que no te pillen por sorpresa más adelante. *Calibración.* A una pistola de luz de verdad hay que decirle dónde está la pantalla, porque es una cámara que mira un monitor desde donde esté el jugador. Las herramientas de servicio de Singe tienen una pantalla Light Gun que pone dianas en el centro y en cada esquina y marca adónde apuntó de verdad la pistola, para que veas hacia qué lado se desvía. Tu juego no hace la calibración y no debería intentarlo. *El borde Sinden.* Una familia de pistolas muy popular encuentra la pantalla buscando un marco brillante dibujado alrededor de la imagen. `--sindengun` dibuja ese marco, y el motor traslada las posiciones a la imagen más pequeña que queda dentro, así que tu juego sigue recibiendo coordenadas que significan lo mismo que antes. *Pantallas que no tienen la forma de la película.* `ratioGetX()` y `ratioGetY()` te dan los números que un jugador ha puesto en la línea de comandos para una pantalla cuyas proporciones no coinciden con las imágenes. Singe no los aplica; un juego que los quiera hace la cuenta por su cuenta. *Pistolas que se van.* `SWITCH_MOUSE_DISCONNECT` llega a `onInputPressed` cuando se desenchufa una pistola. No lo produce ningún botón; lo lanza el motor. Un juego que esté pendiente de él puede poner «JUGADOR 2: PISTOLA DESCONECTADA» en pantalla en lugar de parecer que se ha muerto. *Ningún ratón.* Una recreativa con un joystick y sin dispositivo apuntador puede mover el cursor con el stick, cosa que activa `joyMouseEnable`. Nada de eso cambia el juego que has escrito. Todo ello es la lección treinta y uno. [#_try_it_17] === Pruébalo . *Mueve una diana.* Cambia la `x` de la tercera diana a `680` y ejecútalo. La mayor parte de ella cuelga ahora por fuera del borde derecho, porque el overlay mide 720 de ancho y la diana 96. Todavía puedes disparar a la tira que queda. Decide qué quieres que pase en los bordes. . *Haz las dianas más fáciles.* Cambia `TARGET_SIZE` a `140`. Fíjate en que cambian tanto el dibujo como la comprobación de impacto, y en que solo has tocado un número. Para eso sirve una constante con nombre. . *Lleva la cuenta de la puntería.* Añade una línea que imprima `hits` como porcentaje de `shots`. Protégete contra la división por cero antes del primer disparo, y averigua qué imprime Lua si no lo haces. . *Haz que una diana te castigue.* Añade un campo `penalty = true` a un registro y resta puntos cuando le aciertes. Fíjate en que añades un campo a un registro, no a los cuatro, y en que los que no lo tienen tienen `nil` ahí. . *Quita el `return`* del bucle de `onInputPressed`, y luego solapa dos dianas dándoles los mismos fotogramas y casi la misma posición. Ahora un disparo se lleva las dos. Decide qué comportamiento quiere tu juego. [#_break_it_on_purpose_16] === Rómpelo a propósito Cambia la comprobación del disparo para que use `SWITCH_BUTTON1` en lugar de `SWITCH_BUTTON3` y ejecútalo. No se rompe nada. No aparece ningún error. El botón izquierdo deja de hacer nada, y ahora dispara el *derecho*. Este es el peor tipo de fallo y el más común en el código de entrada: el programa no está mal, solo está conectado a lo que no es. No hay ningún mensaje de error que leer, porque has hecho una pregunta sensata sobre un interruptor que existe y te ha respondido con sinceridad. Hay dos cosas que lo encuentran. La primera es `debugPrint`, de la lección ocho: [source,lua] ---- function onInputPressed(what, device) debugPrint("switch " .. what .. " from device " .. tostring(device)) ---- Ahora cada pulsación imprime una línea, y puedes ver lo que manda de verdad el gatillo. Fíjate en `tostring(device)`: `device` es `nil` cuando se pulsa una tecla, y pegar `nil` a una cadena con `..` es un error. `tostring` lo convierte en la palabra `nil` y la línea se imprime. La segunda es la propia herramienta Input Test del motor, en las herramientas de servicio, que muestra todos los dispositivos que ve y el último interruptor que habría recibido el juego. Cuando un botón no hace nada, esa pantalla te dice qué mitad del problema tienes: o el interruptor está llegando y tu juego no le hace caso, o no está llegando en absoluto. [#_what_you_learned_17] === Lo que has aprendido * Una pistola de luz se presenta como un ratón, así que un juego escrito para el ratón funciona con una pistola. * `mouseGetPosition(device)` devuelve dos valores, la posición del puntero en coordenadas del overlay. * Una función de Lua puede devolver más de un valor, y los recoges poniendo otros tantos nombres. * `singeWantsCrosshairs()` dice si tienes que dibujar tu propio punto de mira. Léelo una vez. * Singe captura y oculta el puntero del sistema al arrancar; no tienes que hacerlo tú. * `onInputPressed` recibe un segundo argumento que dice qué ratón o qué pistola ha disparado, y `nil` cuando ha sido una tecla o un mando. * Una comprobación de impacto contra un rectángulo es `collidePointRect`, la misma colisión que aprendiste en la lección once. * La ventana de fotogramas de una diana no es más que dos campos más en su registro. * `a or b` te da `b` cuando `a` es `nil`. * La calibración, el borde Sinden y el cableado de dos pistolas viven fuera de tu script. [#_next_time_17] === La próxima vez Tu galería pide una sola cosa: dale a eso, ahí. La otra convención de las recreativas sobre la que se construyen los juegos de vídeo pide algo más difícil -- pulsa *esto*, *ya*, y tienes medio segundo. La lección dieciocho construye un quick-time event, con una indicación, una ventana medida en fotogramas y tres finales distintos según hayas acertado, fallado o sido demasiado lento.