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

592 lines
22 KiB
Text

[#_lesson_17_light_guns]
== Lezione 17: Pistole ottiche
image::learn/17-light-guns.png[La lezione finita, 480]
Stai per fare un tiro a segno. I bersagli compaiono sopra il video nei
fotogrammi che scegli tu, tu miri allo schermo e premi un grilletto, e quelli
che colpisci fanno punti.
Per scriverlo non ti serve una pistola ottica. Ti serve un mouse, perché per
Singe una pistola ottica *è* un mouse: riporta una posizione nelle stesse
coordinate, attraverso le stesse chiamate, e il suo grilletto arriva come lo
stesso interruttore di un pulsante del mouse. Scrivi il gioco per il mouse sulla
tua scrivania e funziona su un cabinato con una pistola imbullonata sopra, ed è
l'unico modo sensato di farlo. Una pistola non è un tipo diverso di comando. È
un mouse che qualcuno ha puntato.
[#_what_you_are_pointing_at]
=== A che cosa stai mirando
Comincia come nella quattordicesima lezione, con il video che il motore ha
estratto per te:
----
Singe -R -v Singe/menuBackground.mkv gallery
----
Quel filmato è una griglia viola e un tramonto. Non è una rapina in banca. Ogni
numero di fotogramma di questa lezione si riferisce a lui, così il codice
funziona il giorno stesso in cui lo scrivi, e tutto lo spezzone è 720 per 480 e
lungo circa 420 fotogrammi, che a circa trenta fotogrammi al secondo fanno
quattordici secondi. Immagina un filmato migliore. Al codice non importa che
cosa c'è nell'immagine.
[#_match_the_overlay_to_the_video]
=== Adatta l'overlay al video
Metti questo in cima a `gallery.singe`, sotto la riga del `dofile`:
[source,lua]
----
overlaySetResolution(discGetWidth(), discGetHeight())
----
Senza quella chiamata l'overlay è *metà* del video in ciascuna direzione -- qui
360 per 240 -- e ogni coordinata con cui disegni, e ogni posizione del mouse
che ricevi, sta in quello spazio più piccolo. Non c'è niente di sbagliato
nell'overlay piccolo, ma è una conversione in più da tenere a mente mentre cerchi
di capire dove è finito un colpo. Adattarlo al video vuol dire che un bersaglio
a `x = 80` sta a ottanta pixel dal bordo dell'immagine, e lo stesso vale per un
colpo a `x = 80`. La voce del Riferimento su `overlaySetResolution` spiega quanto
costa.
[#_where_the_gun_is_pointing]
=== Dove punta la pistola
Lo dice una sola chiamata:
[source,lua]
----
local x, y = mouseGetPosition(0)
----
`mouseGetPosition` restituisce due numeri, cosa che non hai ancora visto fare a
una funzione e che a Lua va benissimo: una funzione può restituire tutti i
valori che vuole, e tu li prendi elencando altrettanti nomi a sinistra del
segno `=`.
Lo `0` dice *quale* dispositivo di puntamento ti interessa, contando da zero.
Con un mouse solo, o una pistola sola, la risposta è sempre il dispositivo `0`.
I numeri sono in coordinate dell'overlay, non in pixel del desktop, ed è per
questo che valeva la pena fare la chiamata di prima. La posizione è l'ultima
che il dispositivo ha riportato, quindi puoi chiederla quando vuoi, tutte le
volte che vuoi. Chiedila in `onOverlayUpdate` e disegna con la risposta.
C'è un altro modo di avere gli stessi numeri. `onMouseMoved` è una callback,
come `onOverlayUpdate`, e il motore la chiama ogni volta che un puntatore si
muove, passandole la nuova posizione. È la scelta giusta quando vuoi *reagire*
al movimento. Per disegnare un mirino non lo vuoi: vuoi sapere dov'è la cosa in
questo momento, e `mouseGetPosition` te lo dice senza che tu debba tenerne una
copia.
[#_drawing_a_crosshair]
=== Disegnare un mirino
Il motore non te lo disegna. Un mirino è quattro linee e un cerchio:
[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
----
Gli spazi vuoti contano più delle linee. Fermare i quattro raggi prima del
cerchio ti lascia un buco nel mezzo da cui vedere il bersaglio, che è tutto lo
scopo di un mirino e la prima cosa che i principianti coprono disegnandoci
sopra.
Una cosa che dovresti chiedere prima di disegnarlo:
[source,lua]
----
local drawAim = singeWantsCrosshairs()
----
Restituisce `false` quando il giocatore ha avviato Singe con `--nocrosshair`.
Alcune pistole ottiche mettono sullo schermo un loro puntino, e due mirini a
pochi pixel l'uno dall'altro sono peggio di nessuno. La risposta non può
cambiare mentre il gioco gira, quindi leggila una volta all'inizio e tienila in
una variabile.
Non devi nascondere il puntatore di sistema, perché Singe l'ha già fatto. Si
prende il mouse all'avvio, il che lo confina nella finestra e nasconde la
freccia del desktop. Se mai lo rivuoi -- per un editor di livelli, per dire --
`mouseSetCaptured(false)` lo rilascia, e il tasto assegnato a `INPUT_GRAB`, che
nei comandi forniti è `G`, fa la stessa cosa avanti e indietro mentre giochi.
[#_the_trigger]
=== Il grilletto
Un grilletto è un pulsante, e i pulsanti arrivano a `onInputPressed`, che hai
incontrato per la prima volta nella terza lezione:
[source,lua]
----
function onInputPressed(what, device)
if what ~= SWITCH_BUTTON3 then
return
end
local x, y = mouseGetPosition(device or 0)
-- ...
end
----
`SWITCH_BUTTON3` è il pulsante sinistro del mouse, e su una pistola è il
grilletto. Il `controls.cfg` fornito assegna il pulsante sinistro a
`SWITCH_BUTTON3`, il destro a `SWITCH_BUTTON1` e quello centrale a
`SWITCH_BUTTON2`. Quei numeri sembrano mescolati perché lo sono: gli
interruttori sono i pulsanti del pannello da sala giochi, e quale comando fisico
sia collegato a quale è affare del cabinato, non del gioco. Anche Maiusc
sinistro è assegnato a `SWITCH_BUTTON3`, così puoi sparare senza mouse mentre
fai le prove.
La parte nuova è il secondo argomento. Quando l'interruttore arriva da un mouse
o da una pistola ottica, `device` è l'indice di quel dispositivo -- `0` per il
primo, `1` per il secondo e così via. Quando arriva da un tasto o da un
controller è `nil`, perché non c'era di mezzo nessun dispositivo di puntamento.
Ecco perché la chiamata sopra dice `device or
0`: se `device` è `nil`, usa `0` al suo posto. Userai `or` così di continuo, e
si legge esattamente come suona -- «device, oppure niente, e in quel caso zero».
Quell'unico argomento in più è ciò che rende possibile un cabinato con due
pistole. La pistola del primo giocatore è il dispositivo `0` e quella del
secondo è il dispositivo `1`, e la stessa `onInputPressed` le distingue senza
che tu debba indovinare.
[#_did_it_hit_anything]
=== Ha colpito qualcosa
Sai già come rispondere. È l'undicesima lezione:
[source,lua]
----
if collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then
----
Un colpo è un punto. Un bersaglio è un rettangolo. `collidePointRect` chiede se
il punto sta dentro il rettangolo, e il bordo conta come dentro. Sparare non ha
niente di speciale. La verifica dei colpi di un gioco di pistole è la stessa che
usa un platform per scoprire se sei atterrato su un blocco, e la famiglia
`collide` del Riferimento ha il resto delle forme.
[#_targets_that_come_and_go]
=== Bersagli che vanno e vengono
L'unica cosa che il video cambia è *quando* esiste un bersaglio. Nell'undicesima
lezione una roccia restava lì finché non le sparavi. Qui un bersaglio è sullo
schermo mentre il filmato mostra la parte della scena a cui appartiene, e poi
sparisce, che tu gli abbia sparato o no.
Sono due campi in più nel record, e la sesta lezione ti ha dato lo strumento:
[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 },
}
----
Quattro record, ognuno con il fotogramma in cui compare il bersaglio, il
fotogramma in cui se ne va, e il punto in cui si trova. Aggiungere un quinto
bersaglio vuol dire aggiungere una riga. È la forma a cui ricorrere ogni volta
che ti accorgi che stai per scrivere le stesse quattro righe di codice con
numeri diversi dentro.
Se un bersaglio conta o no è allora una piccola funzione tutta sua:
[source,lua]
----
local function targetIsUp(target, frame)
return not target.hit and frame >= target.first and frame <= target.last
end
----
Glielo chiede il disegno, e glielo chiede lo sparo, ed è proprio questo il
motivo per metterlo in un posto solo: un bersaglio che vedi e un bersaglio che
puoi colpire non possono mai essere in disaccordo.
[#_the_whole_thing_9]
=== Tutto insieme
[source,lua]
----
-- Impara a programmare con Singe -- Lezione 17: Pistole ottiche
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()
----
Mira a un riquadro giallo e fai clic. Il contatore nell'angolo sale, un anello
bianco si apre dove hai colpito, e il riquadro sparisce. Se manchi, sale solo il
conto dei colpi sparati, che è il tabellone più economico che ci sia e dice a un
giocatore come se la sta cavando più di quanto faccia il punteggio. Il giro
ricomincia da solo quando il filmato finisce, e `1` lo fa ricominciare quando
vuoi.
[#_what_just_happened_13]
=== Cos'è appena successo
[source,lua]
----
local frame = discGetFrame()
if frame >= LAST_FRAME then
startRound()
frame = FIRST_FRAME
end
----
Il video è l'orologio, come nella quattordicesima lezione, e `discGetFrame` è il
modo in cui lo leggi. Tutto il resto del fotogramma si decide a partire da quel
numero. Quando il filmato supera la fine dell'ultimo bersaglio, il giro
ricomincia: `startRound` azzera il punteggio, segna tutti i bersagli come non
colpiti e rimanda il disco all'inizio con `discSkipToFrame`. La riga sotto
imposta anche la copia locale di `frame`, perché è stata letta prima del salto e
contiene ancora il numero vecchio. Senza quella riga, il disegno qui sotto passerebbe un fotogramma a
lavorare con un numero che non è più vero.
[source,lua]
----
shots = shots + 1
for _, target in ipairs(targets) do
if targetIsUp(target, frame) and collidePointRect(...) then
----
Conta il colpo prima di cercare un bersaglio colpito, così conta anche un colpo
a vuoto. Poi scorri l'elenco. Il `return` dentro il ciclo si ferma al primo
bersaglio colpito, il che vuol dire che un proiettile non può prendere due
bersagli sovrapposti -- una regola che devi decidere in un senso o nell'altro,
e questa è la riga che la decide.
[source,lua]
----
overlayBox(target.x, target.y, target.x + TARGET_SIZE - 1, target.y + TARGET_SIZE - 1)
----
`overlayBox` vuole due angoli opposti, non un angolo e una dimensione, quindi
l'angolo lontano è quello vicino più la dimensione. Il `- 1` c'è perché tutti e
due gli angoli sono inclusi: un riquadro da `80` a `175` è largo novantasei
pixel. `collidePointRect` invece vuole un angolo e una dimensione, ed è per
questo che lì `TARGET_SIZE` entra da solo e qui sommato all'angolo. Confondere le
due cose è un bug davvero comune, e si vede come bersagli che puoi colpire un
po' fuori da dove sono disegnati.
[source,lua]
----
if flash > 0 then
colorForeground(255, 255, 255, 255)
overlayCircle(flashX, flashY, 30 - flash * 2)
flash = flash - 1
end
----
`flash` è un conto alla rovescia in fotogrammi, messo a dodici quando un colpo
va a segno e diminuito di uno ogni volta che lo schermo viene disegnato. Siccome
il raggio è calcolato *a partire* dal conto alla rovescia, l'anello si allarga
mentre il numero si rimpicciolisce. Dodici fotogrammi sono meno di mezzo
secondo. Una reazione che dura più di così smette di sembrare una conseguenza
del grilletto e comincia a sembrare il tempo che fa.
[#_two_guns]
=== Due pistole
Sei a due modifiche da un cabinato per due giocatori, e nessuna delle due sta
nel ciclo del gioco.
La prima è `mouseSetMode(MOUSE_MANY)`, che dice a Singe di leggere ogni
dispositivo per conto suo invece di riunirli tutti in un unico cursore.
`mouseHowMany()` dice quanti ne ha trovati all'avvio, quindi di solito un gioco
lo chiede prima di impegnarsi:
[source,lua]
----
if mouseHowMany() >= 2 then
mouseSetMode(MOUSE_MANY)
end
----
Dopo di che, `mouseGetPosition(1)` è la seconda pistola, e l'argomento `device`
di `onInputPressed` dice quale pistola ha sparato. Tieni due mirini, due
punteggi e un solo elenco di bersagli, e hai un gioco cooperativo.
La seconda modifica non sta affatto nel tuo script. Il `controls.cfg` fornito
collega solo i pulsanti del *primo* mouse, quindi il grilletto della seconda
pistola non arriva da nessuna parte. Un cabinato con due pistole ha bisogno in
quel file di una riga sua che le nomini tutte e due:
[source,lua]
----
INPUT_ACTION_3 = { SCANCODE.LSHIFT, GAMEPAD_0.BUTTON_X, MOUSE_0.BUTTON_LEFT, MOUSE_1.BUTTON_LEFT }
----
Scrivilo nelle tue istruzioni. Un giocatore la cui seconda pistola non fa
niente darà la colpa al tuo gioco, e avrà ragione a metà.
[#_the_other_way_to_test_a_hit]
=== L'altro modo di verificare un colpo
C'è un secondo modo di rispondere a «quel colpo ha preso qualcosa?», e vale la
pena conoscerlo anche se oggi non lo userai. `vldpGetPixel(x,
y)` legge il colore di un pixel del fotogramma video che è sullo schermo in quel
momento. I giochi lo usano fin dalla primissima versione di Singe: dipingi ogni
bersaglio nel filmato di un colore che non compare da nessun'altra parte, e
quando qualcuno preme il grilletto chiedi di che colore è quello che sta sotto il
mirino.
Ti fa avere gratis forme di colpo che seguono l'azione alla perfezione, senza
rettangoli da preparare. Ti costa un filmato che devi preparare in quel modo, e
una verifica dei colpi che non puoi vedere né mettere a punto. La voce del
Riferimento su `vldpGetPixel` ha i dettagli. I rettangoli sono la scelta giusta di
partenza; questo tienilo in tasca.
[#_what_only_a_cabinet_cares_about]
=== Cose che contano solo per un cabinato
Tutto quello che c'è sopra funziona sulla tua scrivania. Diverse cose sulla tua
scrivania non esistono proprio, e questo è un buon momento per nominarle, così
più avanti non saranno una sorpresa.
*Calibrazione.* A una vera pistola ottica bisogna dire dov'è lo schermo, perché
è una telecamera che guarda un monitor da dovunque si trovi il giocatore. Gli
strumenti di servizio di Singe hanno una schermata Light Gun che mette dei
bersagli al centro e in ogni angolo e segna dove ha puntato davvero la pistola,
così puoi vedere da che parte è sballata. Il tuo gioco non fa la calibrazione e
non dovrebbe provarci.
*Il bordo Sinden.* Una famiglia di pistole molto diffusa trova lo schermo
cercando una cornice luminosa disegnata intorno all'immagine. `--sindengun`
disegna quella cornice, e il motore riporta le posizioni dentro l'immagine più
piccola che sta al suo interno, così il tuo gioco continua a ricevere coordinate
che vogliono dire quello che volevano dire prima.
*Schermi che non hanno la forma del filmato.* `ratioGetX()` e `ratioGetY()` ti
danno i numeri che un giocatore ha indicato sulla riga di comando per uno
schermo le cui proporzioni non corrispondono a quelle del filmato. Singe non li
applica; un gioco che li vuole fa i conti da sé.
*Pistole che se ne vanno.* `SWITCH_MOUSE_DISCONNECT` arriva a `onInputPressed`
quando una pistola viene scollegata. Nessun pulsante lo produce; lo genera il
motore. Un gioco che lo tiene d'occhio può mettere sullo schermo «PLAYER 2 GUN
DISCONNECTED» invece di sembrare morto.
*Niente mouse.* Un cabinato con un joystick e nessun dispositivo di puntamento
può muovere il cursore con la levetta, cosa che si attiva con `joyMouseEnable`.
Niente di tutto questo cambia il gioco che hai scritto. Tutto questo è la
trentunesima lezione.
[#_try_it_17]
=== Prova tu
. *Sposta un bersaglio.* Cambia la `x` del terzo bersaglio in `680` ed eseguilo.
Adesso quasi tutto il bersaglio sporge oltre il bordo destro, perché l'overlay
è largo 720 e il bersaglio 96. Puoi ancora sparare alla striscia che resta.
Decidi che cosa vuoi che succeda ai bordi.
. *Rendi i bersagli più facili.* Cambia `TARGET_SIZE` in `140`. Nota che
cambiano sia il disegno sia la verifica dei colpi, e che hai modificato un
numero solo. È a questo che serve una costante con un nome.
. *Tieni il conto della precisione.* Aggiungi una riga che stampa `hits` come
percentuale di `shots`. Proteggiti dalla divisione per zero prima del primo
colpo, e scopri che cosa stampa Lua se non lo fai.
. *Fai in modo che un bersaglio ti punisca.* Aggiungi un campo
`penalty = true` a un record e togli punti quando viene colpito. Nota che stai
aggiungendo un campo a un record solo, non a tutti e quattro, e che quelli
senza hanno lì `nil`.
. *Togli il `return`* dal ciclo in `onInputPressed`, poi sovrapponi due
bersagli dando loro gli stessi fotogrammi e quasi la stessa posizione. Adesso
un colpo li prende tutti e due. Decidi quale comportamento vuole il tuo gioco.
[#_break_it_on_purpose_16]
=== Rompilo apposta
Cambia la verifica dello sparo perché usi `SWITCH_BUTTON1` invece di
`SWITCH_BUTTON3`, ed eseguilo. Non si rompe niente. Non compare nessun errore.
Il pulsante sinistro smette di fare qualcosa, e adesso spara il pulsante
*destro*.
Questo è il tipo di bug peggiore e il più comune nel codice che gestisce i
comandi: il programma non è sbagliato, è solo collegato alla cosa sbagliata.
Non c'è nessun messaggio di errore da leggere, perché hai fatto una domanda
sensata su un interruttore che esiste e lui ha risposto onestamente.
Lo trovano due cose. La prima è `debugPrint`, dell'ottava lezione:
[source,lua]
----
function onInputPressed(what, device)
debugPrint("switch " .. what .. " from device " .. tostring(device))
----
Adesso ogni pressione stampa una riga, e puoi vedere che cosa manda davvero il
grilletto. Nota `tostring(device)`: `device` è `nil` per la pressione di un
tasto, e attaccare `nil` a una stringa con `..` è un errore. `tostring` lo
trasforma nella parola `nil` e la riga viene stampata.
La seconda è la schermata Input Test del motore, negli strumenti di servizio,
che mostra ogni dispositivo che riesce a vedere e l'ultimo interruttore che il
gioco avrebbe ricevuto. Quando un pulsante non fa niente, quella schermata ti
dice quale metà del problema hai: o l'interruttore arriva e il tuo gioco lo
ignora, o non arriva proprio.
[#_what_you_learned_17]
=== Cosa hai imparato
* Una pistola ottica si presenta come un mouse, quindi un gioco scritto per il
mouse funziona con una pistola.
* `mouseGetPosition(device)` restituisce due valori, la posizione del puntatore
in coordinate dell'overlay.
* In Lua una funzione può restituire più di un valore, e tu li prendi elencando
altrettanti nomi.
* `singeWantsCrosshairs()` dice se disegnare un mirino tuo. Leggilo una volta.
* Singe si prende il puntatore di sistema e lo nasconde all'avvio; tu non devi
farlo.
* `onInputPressed` riceve un secondo argomento che dice quale mouse o pistola ha
sparato, ed è `nil` quando era un tasto o un controller.
* La verifica di un colpo contro un rettangolo è `collidePointRect`, la stessa
collisione che hai imparato nell'undicesima lezione.
* La finestra di fotogrammi di un bersaglio è solo due campi in più nel suo
record.
* `a or b` ti dà `b` quando `a` è `nil`.
* La calibrazione, il bordo Sinden e il collegamento di due pistole stanno fuori
dal tuo script.
[#_next_time_17]
=== La prossima volta
Il tuo tiro a segno chiede una cosa sola: colpisci quello, lì. L'altra
convenzione da sala giochi su cui sono costruiti i giochi video chiede qualcosa
di più difficile -- premi *questo*, *adesso*, e hai mezzo secondo. La
diciottesima lezione costruisce un quick-time event, con una richiesta a
schermo, una finestra misurata in fotogrammi e tre finali diversi a seconda che
tu abbia fatto giusto, sbagliato o troppo tardi.