singe/docs/es/lessons/17-light-guns.adoc

588 lines
22 KiB
Text

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