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