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

617 lines
21 KiB
Text

[#_lesson_18_quick_time_events]
== Lezione 18: Quick-time event
image::learn/18-qte.png[La lezione finita, 480]
Compare una richiesta a schermo. Hai mezzo secondo. Premi la cosa giusta e la
storia va avanti; premi quella sbagliata, o niente del tutto, e va da un'altra
parte.
Questa è tutta l'idea, e un numero sorprendente di giochi video è fatto quasi
solo di questo. Hai già tutti i pezzi: la quattordicesima lezione ti ha dato i
fotogrammi, la sesta un elenco di record, la terza i comandi e la sedicesima
le diramazioni. Questa lezione li mette insieme e aggiunge una cosa davvero
nuova -- una finestra di tempo, e che cosa succede quando si chiude.
[#_the_clock_is_the_film]
=== L'orologio è il filmato
Qui tutto si misura in *fotogrammi*, non in secondi, e vale la pena fermarsi su
questo, perché è l'unica decisione che fa funzionare tutto.
La quattordicesima lezione diceva che un fotogramma è l'indirizzo di un momento
del filmato. La richiesta deve comparire quando l'attore comincia a caricare il
pugno, e la finestra deve chiudersi quando il pugno arriva. Quelli non sono
tempi, sono punti del filmato, e li hai trovati scorrendolo un passo alla volta.
Se scrivi la finestra in secondi devi far partire un cronometro quando compare
la richiesta, e a quel punto i due orologi -- il tuo e quello del filmato --
possono andare fuori sincrono. Se il video scatta su una macchina lenta, il tuo
cronometro continua a correre e il giocatore viene punito per il suo hardware.
I fotogrammi non possono andare fuori sincrono, perché c'è un orologio solo.
`discGetFrame()` è l'ora, sempre. Una finestra è due numeri di fotogramma, e
«quanto manca» è una sottrazione.
Eseguilo con lo stesso video di prima:
----
Singe -R -v Singe/menuBackground.mkv moves
----
Sempre la griglia viola e il tramonto. Immagina uno scontro.
[#_a_move_is_a_record]
=== Una mossa è un record
Ecco la tabella su cui si regge tutta la lezione:
[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" },
}
----
Quattro mosse. Ognuna dice quando si apre la finestra, quando si chiude, quale
interruttore le risponde e che cosa mettere sullo schermo. Quarantacinque
fotogrammi sono circa un secondo e mezzo alla velocità di questo video, il che
è generoso; i giochi veri spesso ti danno meno della metà.
Tutto ciò che fa di una mossa una mossa sta in quella tabella. Nel codice più
sotto non c'è niente che sappia che le mosse sono quattro, o che la seconda è
su, o che la prima finestra si apre al fotogramma sessanta. Aggiungere una
quinta mossa vuol dire aggiungere una riga. Cambiare i tempi di tutte le mosse
vuol dire modificare dei numeri. Nessuno deve leggere una funzione per cambiare
lo scontro.
Non è un trucco per questa lezione. È la differenza tra un gioco che puoi
mettere a punto e uno che non puoi, e farlo fin dall'inizio non costa niente.
Quando ti accorgi che stai per scrivere `if frame == 60 then` per la quarta
volta, fermati e fai invece una tabella.
[#_what_happens_when]
=== Che cosa succede quando
A una mossa possono succedere tre cose, e sono tre esiti diversi, non due:
* Il giocatore preme l'interruttore giusto dentro la finestra. Assegna i punti e
si va avanti.
* Il giocatore preme un interruttore *sbagliato* dentro la finestra. Ci ha
provato e ha fallito.
* La finestra si chiude senza nessuna risposta. Si è bloccato.
Il codice pigro tratta allo stesso modo gli ultimi due. I giochi non lo fanno
quasi mai, perché giocandoci danno sensazioni completamente diverse: tirare a
indovinare e sbagliare è un errore, bloccarsi è un errore diverso, e di solito
il filmato va in un posto diverso per ciascuno. Qui la risposta sbagliata manda
il disco al fotogramma `362` e la risposta mancata lo manda al fotogramma `388`,
il che su questo filmato vuol dire due quantità di tramonto un po' diverse. Su
un filmato vero sarebbero due inquadrature diverse di te che perdi.
Quindi ogni record mette su un quinto campo mentre la partita è in corso --
`answer` -- che è `nil` finché alla mossa non succede qualcosa, e poi una di
tre stringhe:
[source,lua]
----
move.answer = "right"
move.answer = "wrong"
move.answer = "late"
----
`nil` è il valore che Lua ti dà per un campo che nessuno ha impostato. L'hai
incontrato nella terza lezione. Qui si guadagna il pane: «nessuna risposta
ancora» è davvero uno stato diverso da tutte e tre le risposte, e `nil` lo dice
senza che tu debba inventarti una parola apposta.
[#_finding_the_move_that_is_being_asked]
=== Trovare la mossa richiesta
Tutto il resto ha bisogno di sapere una cosa: quale mossa, se ce n'è una, è
aperta in questo momento?
[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
----
Scorri l'elenco, e rispondi con la prima mossa la cui finestra copre questo
fotogramma e che non ha ancora avuto risposta. Se non corrisponde niente,
rispondi `nil`.
Vale la pena notare che restituisce `nil` apposta. Una funzione che a volte non
ha una risposta dovrebbe dirlo, e «nessuna mossa è aperta» è una risposta più
che valida. Chi la chiama scrive allora `if move ~= nil then`, e il codice che
disegna la richiesta non viene mai eseguito quando non c'è niente da chiedere.
La verifica `move.answer == nil` è ciò che impedisce a una mossa di essere
chiesta due volte. Nell'istante in cui il giocatore risponde giusto, la
richiesta sparisce anche se la finestra è ancora aperta -- ed è proprio quello
che deve fare, perché ha risposto.
[#_reading_the_answer]
=== Leggere la risposta
[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
----
Leggila dall'alto in basso. Se l'interruttore non è una delle quattro
direzioni, non sono affari nostri -- vattene. Se nessuna mossa è aperta, premere
una direzione non vuol dire niente -- vattene. Altrimenti c'è esattamente una
mossa con cui confrontare, e un confronto da fare.
Quelle due righe `return` in anticipo sono delle guardie: buttano fuori i casi
che non ti interessano, così il codice sotto viene eseguito solo nell'unica
situazione per cui è stato scritto. Scriverlo al contrario, con il lavoro vero
annidato tre livelli più in fondo dentro tre blocchi `if`, dice la stessa cosa
ed è molto più difficile da leggere.
`isDirection` è una funzione minuscola che non fa altro che tenere corta quella
prima riga:
[source,lua]
----
local function isDirection(what)
return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT
end
----
Nota che restituisce direttamente il confronto invece di dire `if ... then
return true else return false end`. Un confronto è già `true` o `false`.
Verificare se qualcosa è vero per poter dire che è vero è un'abitudine da
perdere presto.
[#_the_window_closing]
=== La finestra che si chiude
Nessuno preme niente, e i fotogrammi continuano a passare. Qualcosa deve
accorgersene. Quel qualcosa è `onOverlayUpdate`, perché viene eseguita a ogni
fotogramma, che il giocatore faccia qualcosa 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
----
Una mossa che non ha avuto risposta e il cui ultimo fotogramma è passato è
stata mancata. `break` esce subito dal ciclo, perché una volta finita la
partita non ha senso guardare il resto.
`endRun` è la diramazione, ed è la stessa diramazione che hai scritto nella
sedicesima lezione:
[source,lua]
----
local function endRun(reason, frame)
verdict = reason
runOver = true
discSkipToFrame(frame)
end
----
Tre righe. Ricordati che cosa dire, ricordati che la partita è finita, e manda
il filmato da un'altra parte. Un quick-time event mancato non è un meccanismo
speciale. È una scelta che il giocatore ha fatto non facendone nessuna, e il
filmato si dirama su di essa esattamente come si dirama su una porta che ha
scelto.
[#_a_gauge_that_drains]
=== Un indicatore che si svuota
Il giocatore non vede i numeri dei fotogrammi. Deve vedere il tempo che finisce,
e deve vederlo senza distogliere lo sguardo dall'azione, il che vuol dire una
forma che cambia invece di un numero che conta.
Due numeri ti danno tutto:
[source,lua]
----
local left = move.last - frame
local span = move.last - move.first
----
`span` è quanto è lunga la finestra. `left` è quanta ne resta. La barra è
`left` su `span` della larghezza piena:
[source,lua]
----
fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H)
----
`//` è la divisione che butta via la parte frazionaria, ed è quello che vuoi
qui, perché una barra larga centotrentatré pixel e un terzo non esiste.
Moltiplica prima di dividere -- `GAUGE_W * left // span` e non `GAUGE_W * (left //
span)` -- altrimenti la parte frazionaria viene buttata via quando è ancora
l'unica informazione che hai, e la barra salta da piena a vuota senza niente in
mezzo.
Nell'overlay non c'è un rettangolo pieno. `overlayBox` disegna un contorno, che
è quello che ti serve per la cornice intorno all'indicatore, e il riempimento
lo fai tu:
[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
----
Diciotto linee orizzontali impilate una sull'altra. La guardia in cima conta:
all'ultimissimo fotogramma della finestra `left` vale zero, la larghezza vale
zero, e senza la guardia `x + width - 1` sta un pixel a *sinistra* di `x` e
disegni una piccola linea all'indietro invece di niente.
L'ultimo tocco è un cambio di colore quando la situazione si fa disperata:
[source,lua]
----
if left * 3 < span then
colorForeground(255, 60, 60, 255)
else
colorForeground(255, 200, 0, 255)
end
----
La barra diventa rossa nel suo ultimo terzo. Scritto così -- moltiplicando il
tempo che resta invece di dividere la durata -- resta in numeri interi e
funziona per una finestra di qualsiasi lunghezza.
[#_the_whole_thing_10]
=== Tutto insieme
[source,lua]
----
-- Impara a programmare con Singe -- Lezione 18: Quick-time event
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()
----
I tasti freccia rispondono alle richieste. Azzeccale tutte e quattro e il
filmato va fino in fondo e si ferma lì su `CLEAR`. Sbagliane una, o resta con le
mani in mano, e il filmato salta a un fotogramma diverso con una parola diversa
sullo schermo. `1` lo fa ripartire.
[#_what_just_happened_14]
=== Cos'è appena successo
La maggior parte l'hai già letta. Quattro pezzi meritano una seconda occhiata.
[source,lua]
----
if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then
----
La fine del filmato va intercettata, altrimenti il filmato va oltre la fine e
non c'è più niente da vedere. `discSearch` va a un fotogramma e lì si *mette in
pausa*, ed è esattamente quello che vuole un'inquadratura finale. La metà con
`discGetState()` impedisce che questo succeda di nuovo a ognuno dei fotogrammi
successivi: una volta in pausa il disco non è più `DISC_PLAYING`, quindi la
condizione è falsa e il salto non viene ripetuto sessanta volte al secondo.
`discGetState` e le costanti `DISC_*` sono nel Riferimento, sotto
`discGetState`.
[source,lua]
----
local move = nil
if not runOver then
move = moveAt(frame)
end
----
`onOverlayUpdate` chiede di nuovo qual è la mossa aperta, invece di farselo
dire da `onInputPressed`. È voluto. C'è esattamente una funzione che sa quale
mossa è aperta, e tutti chiedono a lei. Se la richiesta venisse da una
variabile tenuta aggiornata dal codice dei comandi, allora due pezzi di codice
si ricorderebbero lo stesso fatto, e prima o poi uno dei due sbaglierebbe.
Quando devi scegliere tra chiedere e ricordare, chiedi.
[source,lua]
----
overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name)
----
`overlayPrint` conta in celle di carattere, come fa dalla prima lezione, quindi
la richiesta è posizionata a occhio: la colonna cinquantaquattro è abbastanza
vicina al centro per parole di questa lunghezza. È abbastanza vicina, non
giusta, e «PRESS RIGHT» finisce tre caratteri più a destra di «PRESS UP». La
diciannovesima lezione carica un vero font, e con lui la possibilità di
misurare una stringa in pixel e centrarla come si deve.
[source,lua]
----
function onInputPressed(what)
if runOver then
if what == SWITCH_START1 then
startRun()
end
return
end
----
Una callback, due lavori completamente diversi, scelti da `runOver`. Mentre la
partita è in corso le direzioni sono mosse; una volta finita, l'unico tasto che
vuol dire qualcosa è start. Una variabile che decide quali regole valgono è uno
*stato*, che la dodicesima lezione chiamava proprio così, e un gioco è fatto
soprattutto di un piccolo numero di stati.
[#_try_it_18]
=== Prova tu
. *Rendilo più difficile.* Cambia il `last` della prima mossa da `105` a `75`.
Quindici fotogrammi sono mezzo secondo. Giocaci e scopri se ce la fai.
. *Aggiungi una mossa.* Metti una quinta riga nella tabella con una finestra tra
i fotogrammi `370` e `400`, e porta `LAST_FRAME` a `418`. Non dovresti dover
toccare nessun'altra riga. Se devi farlo, qualcosa nel codice sa troppo della
tabella.
. *Dai punti alla velocità.* Assegna più punti a chi risponde presto. In
`onInputPressed`, dove viene premiata la risposta giusta, calcola `left` e
`span` a partire da `move` e `discGetFrame()` come fa l'indicatore, e aggiungi
`RIGHT_SCORE * left // span` invece di `RIGHT_SCORE`. Decidi se così il gioco
diventa migliore o solo più movimentato.
. *Da' alla risposta sbagliata una conseguenza sua.* Invece di far finire la
partita, fai costare cinquanta punti a una pressione sbagliata e lascia la
finestra aperta per riprovare. Dovrai smettere di impostare `move.answer`, e
incontrerai il motivo per cui veniva impostata.
. *Due richieste insieme.* Sovrapponi le finestre di due mosse e guarda che cosa
succede. `moveAt` restituisce la prima corrispondenza, quindi ne viene chiesta
sempre una sola. Decidi che cosa vorresti, e che cosa ci vorrebbe.
[#_break_it_on_purpose_17]
=== Rompilo apposta
Cancella il `break` dal ciclo che trova le mosse mancate, e manca la prima
apposta. La partita finisce, il disco salta, e sembra tutto a posto.
Ora metti `debugPrint(move.name .. " missed")` accanto a `move.answer = "late"`
e rifallo. La console mostra una riga, come ti aspetteresti.
Poi togli anche il blocco `if not runOver then` intorno al ciclo, e manca di nuovo la
prima mossa:
----
LEFT missed
UP missed
RIGHT missed
DOWN missed
----
La prima riga arriva quando la finestra si chiude, come prima. Le altre tre
arrivano insieme proprio al fotogramma successivo, nell'istante in cui il disco
atterra sul 388: ogni finestra rimasta ormai è nel passato, quindi ogni mossa
senza risposta viene mancata tutta in una volta, e ognuna chiama `endRun` e fa
un altro salto. Il gioco più o meno funziona ancora, ed è questo che lo rende
pericoloso. Tre salti in più in un solo fotogramma, tre sovrascritture del
verdetto, e niente sullo schermo lo dice.
Anche qui non c'è nessun messaggio di errore. La lezione è che quel `break` e
quella guardia non sono questione di ordine. Sono le due righe che dicono «questo
è già stato deciso», e il codice che continua a decidere una cosa che ha già
deciso è una delle fonti di bug più affidabili che scriverai mai.
[#_what_you_learned_18]
=== Cosa hai imparato
* Un quick-time event è una richiesta, una finestra e una conseguenza.
* Misura la finestra in fotogrammi, perché il filmato è l'orologio e di
orologio ce ne deve essere sempre uno solo.
* Tieni le mosse in una tabella di record, così aggiungere una mossa vuol dire
aggiungere una riga.
* Giusto, sbagliato e nessuna risposta sono tre esiti, e di solito portano in
tre posti diversi.
* `nil` in un campo di un record è uno stato vero: nessuno ha ancora risposto.
* Una sola funzione risponde a «quale mossa è aperta», e tutti chiedono a lei
invece di tenerne una copia.
* Le clausole di guardia -- un `return` anticipato per i casi che non ti
interessano -- evitano di annidare il lavoro vero.
* `//` divide e butta via la parte frazionaria; moltiplica prima di dividere.
* `overlayBox` disegna un contorno, quindi una barra piena è una pila di
chiamate a `overlayLine`.
* Una mossa mancata è una diramazione, e la prende `discSkipToFrame`, esattamente
come nella sedicesima lezione.
[#_next_time_18]
=== La prossima volta
La terza parte è finita. Sai riprodurre un filmato, aspettarlo, diramarlo,
mirarci e misurare il tempo del giocatore contro di lui. Quello che hai scritto
sopra a tutto questo è il font della console del motore, che è largo sei pixel e
pensato per la diagnostica. La diciannovesima lezione carica un vero carattere
tipografico, lo misura, e fa sembrare le parole sullo schermo parte del gioco.