[#_lesson_28_online] == Lezione 28: Online image::learn/28-online.png[La lezione finita, 480] Nella dodicesima lezione hai salvato un punteggio record. La mattina dopo è ancora lì, che è quasi tutto quello a cui serve un punteggio record, ma è un punteggio record su una sola macchina. Questa lezione mette un punteggio su una classifica che possono vedere anche gli altri. È anche la lezione sul fallimento. Tutto il resto che hai scritto fallisce per motivi che puoi trovare: manca un file, un numero è sbagliato, il nome di una funzione è scritto male. La rete fallisce per motivi che nessuno nella stanza può vedere. Qualcuno ha staccato un cavo in un edificio in cui non sei mai stato, e il tuo gioco deve continuare a girare. Quasi tutto quello che segue parla di questo. [#_what_the_service_is] === Che cos'è il servizio Singe può parlare con un *servizio master*, un server che custodisce tre cose: * un *account*, che appartiene alla macchina e non a un singolo gioco; * un *catalogo* di giochi da scaricare e aggiornare; * *classifiche dei punteggi record*, una o più per gioco. Il servizio predefinito è `master.singeengine.com`, e un gestore che ha un servizio suo indirizza la macchina altrove. La sezione del manuale sul servizio online descrive entrambe le parti. Ora la parte che tutti saltano. Il servizio *non* è multigiocatore: non c'è modo che due giocatori stiano nella stessa partita. Non è un cloud per i salvataggi; il salvataggio della dodicesima lezione resta sulla macchina. Non è una protezione dalla copia, e niente al suo interno impedisce a qualcuno di giocare al tuo gioco. Non è obbligatorio: un gioco che non accede mai funziona esattamente come ha sempre fatto, ed è l'unica impostazione predefinita sensata per qualcosa che ha bisogno di una connessione funzionante. Quindi la classifica è un extra. Tieni il punteggio record locale della dodicesima lezione, continua a mostrarlo, e lascia che la classifica online sia la cosa che a volte c'è. [#_signing_in_happens_once_and_not_in_your_game] === L'accesso si fa una volta, e non nel tuo gioco L'account appartiene alla macchina. Lo si crea e ci si accede dal menu incluso, dietro il tasto assegnato a `INPUT_SERVICE`, nello strumento chiamato *Online Account*. Il token che ottiene viene scritto in `master.dat` nella radice dei dati, un livello sopra la cartella dei dati di ogni gioco, così ogni gioco sulla macchina invia i punteggi con lo stesso accesso. Il tuo gioco non chiede mai un indirizzo email e non chiede mai una password. Non c'è nessuna funzione per farlo, ed è voluto: un giocatore che scrive una password in un gioco che ha scaricato non ha modo di sapere dove va a finire. Quello con cui un giocatore compare su una classifica è un *nickname*: da tre a venti caratteri tra lettere, cifre, trattini e trattini bassi, che iniziano e finiscono con una lettera o una cifra. Niente spazi, niente punti e niente `@`, così un nickname non può nemmeno sembrare un indirizzo email. È tenuto separato dall'account, è l'unica cosa di un giocatore che gli altri giocatori vedono, e si sceglie una volta sola -- un gestore può cambiarne uno, un giocatore no, perché una classifica i cui nomi continuano a cambiare non è davvero la storia di nessuno. Finché non è stato scelto un nickname i punteggi vengono rifiutati, dato che un punteggio senza il nome di nessuno sopra non sta su una classifica in nessun senso utile. Lo strumento Online Account mostra il nome con cui questa macchina invia i punteggi, e il tuo gioco lo legge con `scorePlayerName()`, che risponde `nil` quando non ce n'è uno. Meglio saperlo prima che ti lasci perplesso: un punteggio inviato senza un nickname dietro viene rifiutato dal *servizio*, non da `scoreSubmit`, quindi al tuo gioco non viene detto niente. Se `scorePlayerName()` è `nil`, dillo sullo schermo finché c'è ancora qualcuno lì a leggerlo. [#_your_game_needs_an_id] === Il tuo gioco ha bisogno di un id Una classifica deve essere conservata sotto qualcosa. Quel qualcosa è `GAME_ID` nella voce del tuo gioco in `games.dat`, il file che hai creato nella tredicesima lezione: [source,lua] ---- GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10" ---- È un UUID, esattamente in quella forma: otto cifre esadecimali, tre gruppi di quattro, poi dodici, separati da trattini. Generane uno come preferisci e incollalo; non importa quale ti capita, solo che non cambi mai più. La voce del Riferimento per `singeGetGameId` spiega perché non viene accettato nient'altro. `singeGetGameId()` lo restituisce, o `nil` quando la voce non ne ha uno. Un gioco senza id si può giocare ma non può finire in classifica, e chiedere prima è il modo per evitare di offrire una classifica che non potrà mai funzionare: [source,lua] ---- if singeGetGameId() then -- showLeaderboardButton è una tua funzione, non una di Singe. showLeaderboardButton() end ---- [#_the_six_calls] === Le sei chiamate Tutto quello che un gioco fa con il servizio sono sei funzioni, e vengono da `Singe/Framework.singe`, quindi qui il `dofile` in cima al tuo script non è facoltativo: [source,lua] ---- dofile("Singe/Framework.singe") ---- `scoreBegin()` dice che sta cominciando una partita. `scoreSubmit(value)` mette in coda un punteggio. `scoreBoard(onDone)` recupera la classifica. `scorePlayerName()` è il nickname. `scoreWaiting()` è quanti punteggi non sono ancora partiti. E `scoreUpdate()` deve girare una volta per fotogramma, da `onOverlayUpdate`, altrimenti nessuna delle altre arriva mai in fondo. Quest'ultima è tutto il trucco di questa lezione, quindi la diciamo due volte. Niente di tutto questo parla con la rete quando lo chiami. `scoreSubmit` scrive in una coda e ritorna subito. `scoreBoard` si ricorda che cosa vuoi e ritorna subito. Il lavoro vero avviene un pezzetto alla volta dentro `scoreUpdate`, tra un fotogramma e l'altro, ed è per questo che il tuo gioco non va mai a scatti quando la linea è lenta. Togli `scoreUpdate` e non si rompe niente, non c'è nessun errore, e non succede mai niente. [source,lua] ---- function onOverlayUpdate() scoreUpdate() overlayClear() return OVERLAY_UPDATED end ---- [#_posting_a_score] === Inviare un punteggio Alla fine di una partita: [source,lua] ---- if scoreSubmit(score) then fetchBoard() else saying = "Kept here only: " .. whyNotPosted() end ---- `scoreSubmit` risponde `false` quando non c'è un account o il gioco non ha un `GAME_ID`, e `true` quando il punteggio è entrato nella coda. Nota che cosa *non* fa il ramo `else`: non butta via il punteggio, non si profonde in scuse e non impedisce al giocatore di cominciare un'altra partita. Dice una sola riga breve, perché il giocatore non ha sbagliato niente e da qui non può rimediare. La coda è il motivo per cui tutto questo è sicuro. Un punteggio in coda viene scritto su disco, quindi sopravvive allo spegnimento della macchina, e viene consegnato al servizio la volta dopo che c'è una connessione -- un'ora dopo, o martedì prossimo. Ecco perché un cabinato in una sala con un wifi scadente finisce comunque sulla classifica. Sii onesto con te stesso sul limite di quella promessa: la coda conserva i punteggi per una macchina che *ha* un account. Se nessuno ha mai effettuato l'accesso, `scoreSubmit` risponde `false` e il punteggio non viene conservato per dopo, perché non c'è nessuno per cui conservarlo. `scoreWaiting()` ti dice quanti sono ancora in coda, e mostrare quel numero da qualche parte è una gentilezza: è la differenza tra «il servizio è rotto» e «tre dei tuoi punteggi aspettano che torni internet». `scoreBegin()` merita un paragrafo tutto suo. Chiamala quando comincia una partita, e il servizio annota il momento con il proprio orologio. Il punteggio che segue porta con sé quanto è durata la partita, misurato sul server, ed è l'unico numero di un invio che non è passato dalla macchina del giocatore. Niente viene rifiutato in base a quello: viene mostrato a chi gestisce il servizio, accanto al punteggio, e decide una persona. Una macchina che era offline quando è cominciata la partita non registra nessun tempo, il che è onesto, non rotto. E visto che siamo onesti: un punteggio inviato è una *dichiarazione*. Il gioco gira sul computer del giocatore, quindi niente di ciò che può fare il client lo rende qualcosa di più, e una chiave segreta inclusa nel tuo gioco sembrerebbe soltanto sicurezza. Il servizio registra chi ha dichiarato che cosa, limita la velocità con cui possono arrivare i punteggi e segnala quelli assurdi perché li guardi una persona. Progetta la tua classifica sapendolo. [#_reading_the_board_back] === Rileggere la classifica `scoreBoard` è la prima funzione di questo libro che prende una funzione come argomento. [source,lua] ---- scoreBoard(function(ok, result) ... end) ---- Hai scritto delle funzioni nella quinta lezione e hai dato loro un nome. Questa non ha nome: è scritta dove viene usata, passata direttamente a `scoreBoard` e chiamata più tardi -- magari secondi dopo -- quando arriva la risposta. È di nuovo una *callback*, la stessa idea di `onOverlayUpdate`, solo che stavolta scegli tu chi la chiama. `ok` è `true` o `false`. Quando è `false`, `result` è una breve stringa che dice perché. Quando è `true`, `result` è una tabella con due parti: * `result.top`, un elenco di righe, ognuna con un `name` e un `value`; * `result.standing`, il `value` di questo giocatore, il suo `rank` e il numero di `players` sulla classifica -- che può mancare, se questo giocatore non ci ha niente sopra. Tra la chiamata e la callback il tuo gioco continua a girare e a disegnare. Quell'intervallo è reale e il giocatore lo vede, quindi mettici qualcosa. Lo script qui sotto imposta una riga di testo a `"Asking the service..."` prima della chiamata, e la callback sostituisce quella riga con la classifica o con il motivo per cui non c'è. Una schermata che non dice niente per quattro secondi sembra rotta; una schermata che dice che cosa sta facendo è solo lenta. [#_the_catalogue] === Il catalogo La terza cosa che fa il servizio è elencare giochi. Lo strumento *Get Games* del menu mostra tutto quello che il servizio offre, segna ciò che è già installato su questa macchina, e lo scarica, lo aggiorna o lo rimuove. Un download viene controllato rispetto all'impronta pubblicata dal catalogo prima di sostituire qualsiasi cosa, così un trasferimento arrivato danneggiato fallisce invece di installare un gioco che non partirà. Un gioco che il servizio ha ritirato continua a funzionare e appare come installato ma non più offerto: smette soltanto di ricevere aggiornamenti, perché chi ha un gioco ce l'ha. Il tuo gioco non chiama niente di tutto questo, e non dovrebbe. Il catalogo ti riguarda come autore di un gioco più che come chi scrive uno script: è dove va il tuo file `.game` quando lo pubblichi, che è l'argomento della trentesima lezione, ed è un altro motivo per cui il tuo `games.dat` ha bisogno di un `GAME_ID` che non cambi mai. Se un giorno vuoi davvero l'elenco da dentro uno script, `Singe/Master.singe` è il modulo che sta sotto tutto questo, e vale la pena leggerlo una volta. [#_what_the_service_knows_about_a_player] === Che cosa sa il servizio di un giocatore Se distribuisci un gioco che invia punteggi, stai consegnando i dati di qualcun altro a un server, e questa è una tua responsabilità anche se il server non l'hai scritto tu. Quindi dillo chiaramente, nella schermata di informazioni o nel file leggimi del tuo gioco: * Il servizio conserva un *indirizzo email* e una password per l'account. L'indirizzo serve a recuperare una password dimenticata e a nient'altro. Non viene mai mostrato agli altri giocatori e non si può cambiare in seguito. * Il *nickname* è quello che compare sulle classifiche. È tenuto separato dall'account apposta, così che gli altri giocatori non vedano mai un indirizzo, e viene controllato rispetto a un elenco di nomi che nessuno dovrebbe rivendicare e di parole che non stanno bene su uno schermo in una sala pubblica. * Un *punteggio* porta con sé il suo valore, la classifica su cui sta, l'ora registrata dal servizio e il nickname. Tutto qui. * Il catalogo sa *quali giochi sono installati* sulla macchina, e in quale versione, perché è così che ti offre un aggiornamento. Quello che nessuna di queste cose fa è seguire un giocatore da un gioco all'altro, vendere qualcosa o frugare nella macchina in cerca di qualcosa che non hai mandato tu. Il tuo gioco non dovrebbe allungare l'elenco. Se aggiungi informazioni extra a un punteggio -- `scoreSubmit` prende un terzo argomento facoltativo apposta -- fa' che riguardino il gioco. [#_the_script] === Lo script Un test di reazione: aspetta la parola, premi il pulsante, il punteggio è quello che resta di mille. È un piccolo gioco, e tutto il resto è la parte online. Crea una cartella chiamata `reaction` e metti questo in `reaction.singe`, al suo interno. Dai alla cartella un `games.dat` come quello della tredicesima lezione, che indichi `reaction/reaction.singe`, con nella sua voce la riga `GAME_ID` di prima. [source,lua] ---- dofile("Singe/Framework.singe") local WAIT_MIN = 1000 local WAIT_MAX = 3000 local PERFECT = 1000 local state = "attract" local goAt = 0 local score = 0 local best = saveGet("best", 0) local saying = "Button 1 to play." local top = nil local standing = nil local function whyNotPosted() if not singeGetGameId() then return "this game has no GAME_ID." end return "this machine is not signed in." end local function fetchBoard() top = nil standing = nil saying = "Asking the service..." scoreBoard(function(ok, result) if not ok then saying = "No board: " .. tostring(result) return end top = result.top standing = result.standing if #top == 0 then saying = "The board is empty. You could be first." else saying = "" end end) end local function finish(reaction) score = PERFECT - reaction if score < 0 then score = 0 end if score > best then best = score saveSet("best", best) end state = "result" if scoreSubmit(score) then fetchBoard() else saying = "Kept here only: " .. whyNotPosted() end end local function play() state = "waiting" goAt = singeGetTicks() + math.random(WAIT_MIN, WAIT_MAX) score = 0 top = nil standing = nil saying = "" scoreBegin() end function onInputPressed(what) if what ~= SWITCH_BUTTON1 then return end if state == "waiting" then state = "result" score = 0 saying = "Too soon. Nothing posted." elseif state == "go" then finish(singeGetTicks() - goAt) else play() end end function onOverlayUpdate() scoreUpdate() if state == "waiting" and singeGetTicks() >= goAt then state = "go" end overlayClear() overlayPrint(2, 0, "REACTION TEST") if state == "attract" then overlayPrint(2, 2, "Button 1 to play.") elseif state == "waiting" then overlayPrint(2, 2, "Wait for it...") elseif state == "go" then overlayPrint(2, 2, "NOW!") else overlayPrint(2, 2, "You scored " .. score .. ". Button 1 plays again.") end overlayPrint(2, 4, "Best on this machine: " .. best) overlayPrint(2, 5, "Posting as: " .. (scorePlayerName() or "nobody yet")) overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting()) overlayPrint(2, 8, saying) if top then for place, row in ipairs(top) do if place > 5 then break end overlayPrint(2, 9 + place, place .. ". " .. row.name .. " " .. row.value) end end if standing then overlayPrint(2, 16, "You are " .. standing.rank .. " of " .. standing.players .. ".") end return OVERLAY_UPDATED end ---- Eseguilo con `Singe -R reaction`. Su una macchina che non ha mai effettuato l'accesso, il gioco gira, tiene il tuo punteggio migliore e dice `Kept here only: this machine is not signed in.` Questa è l'esecuzione importante, ed è quella che avrà la maggior parte dei tuoi giocatori. [#_what_just_happened_23] === Cos'è appena successo [source,lua] ---- local top = nil local standing = nil ---- Due variabili che sono `nil` quasi sempre. `nil` vuol dire «qui non c'è niente», ed è quello che contiene una variabile prima che ci si metta dentro qualcosa. Queste contengono la classifica quando ce n'è una, e `nil` quando non c'è: mentre la richiesta è in viaggio, dopo un errore e prima della prima partita. Il codice che disegna chiede `if top then` e non disegna niente quando non c'è niente, che è la forma di quasi ogni pezzo di codice di rete che scriverai. [source,lua] ---- saying = "Asking the service..." scoreBoard(function(ok, result) ---- L'ordine conta. La riga di testo viene impostata *prima* della chiamata, perché la chiamata ritorna subito e la callback potrebbe non partire per qualche secondo. Se imposti il testo solo dentro la callback, lo schermo mostra il messaggio della partita precedente finché non arriva la risposta, cioè esattamente il messaggio sbagliato nel momento esattamente sbagliato. [source,lua] ---- if not ok then saying = "No board: " .. tostring(result) return end ---- Il fallimento viene per primo, ed è breve. `tostring` trasforma in testo qualunque cosa sia tornata, così che unirla con `..` non possa fallire; `result` è una stringa quando `ok` è falso, ma una riga di codice di disegno che si fida del tipo di qualcosa ricevuto da un server è una riga che aspetta solo di andare in crash. Il giocatore vede una frase, e il gioco va avanti. [source,lua] ---- if scoreSubmit(score) then fetchBoard() else saying = "Kept here only: " .. whyNotPosted() end ---- `scoreSubmit` che risponde `false` non è un errore. È un fatto che riguarda questa macchina, e vale la pena distinguere i due fatti che può voler dire, che è quello che fa `whyNotPosted`: un `GAME_ID` mancante è un errore *tuo*, e una macchina che non ha effettuato l'accesso non è colpa di nessuno. Il punteggio migliore locale era già stato salvato, due righe sopra, prima di tentare tutto questo. Prima salva, poi invia, sempre. [source,lua] ---- overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting()) ---- La coda, sullo schermo. Su una macchina con una connessione segna `0` per sempre e nessuno ci fa caso. Su una macchina senza, sale, e il giocatore sa che il gioco sta conservando i suoi punteggi invece di mangiarseli. [source,lua] ---- function onOverlayUpdate() scoreUpdate() ---- Prima riga del fotogramma, prima di qualsiasi altra cosa. Mettila in un punto in cui un `if` non possa saltarla, perché uno `scoreUpdate` che gira solo nella schermata di game over è una coda che si svuota solo nella schermata di game over. [#_try_it_28] === Prova tu . *Stacca la spina.* Scollega la macchina dalla rete, gioca qualche partita e guarda il numero di `Waiting to send`. Ricollegala e guardalo scendere senza che tu faccia niente. . *Cancella l'id.* Togli `GAME_ID` dal tuo `games.dat` ed eseguilo di nuovo. Il messaggio cambia. Rimettilo. . *Mostra più classifica.* Il ciclo si ferma a cinque righe. L'overlay è alto diciotto righe di caratteri per impostazione predefinita, quindi calcola quante ne puoi far stare davvero, e sposta la riga di `standing` se serve. . *Dai un nome alla classifica.* `scoreSubmit(score, "reaction")` e `scoreBoard(onDone, "reaction")` usano una classifica con quel nome invece di quella che porta il nome del tuo script. Invia a due classifiche dallo stesso gioco -- per esempio, una per il punteggio e una per la singola reazione più rapida -- e fai che il pulsante 2 cambi quale viene mostrata. . *Rendi visibile l'attesa.* Tra la chiamata a `scoreBoard` e il momento in cui parte la callback, disegna un puntino che si muove. Poi scopri quanto dura davvero sulla tua connessione, e se valeva la pena disegnarlo. [#_break_it_on_purpose_27] === Rompilo apposta Togli la prima riga di `onOverlayUpdate`, così la chiamata a `scoreUpdate()` sparisce, e gioca una partita. Non succede niente. Nessun errore, nessun messaggio, nessuna classifica, e il numero di `Waiting to send` sale e resta su per sempre. Il gioco funziona perfettamente. Questo è il peggior tipo di bug, e vale la pena incontrarlo una volta apposta: un passo mancante che produce silenzio invece di una lamentela. Non c'è nessun numero di riga da guardare, perché non è andato storto niente -- hai chiesto del lavoro e poi non gli hai mai dato occasione di succedere. Quando qualcosa in Singe che prende una callback non ti richiama mai, la prima domanda è sempre se la cosa che lo fa andare avanti gira a ogni fotogramma. L'altro che incontrerai è più rumoroso. Rimetti `scoreUpdate()`, cancella la riga del `dofile` in cima ed eseguilo: ---- Error executing function 'onOverlayUpdate': reaction/reaction.singe:89: attempt to call a nil value (global 'scoreUpdate') stack traceback: reaction/reaction.singe:89: in function 'onOverlayUpdate' ---- La riga 89 è lo `scoreUpdate()` che hai rimesso, una riga più in alto ora che il `dofile` non c'è più. Leggi la prima riga come sempre: la funzione che il motore stava chiamando, il file, la riga e la lamentela. Di nuovo `nil` -- il nome `scoreUpdate` non contiene niente, perché il file che lo definisce non è mai stato caricato. Tutte e sei queste chiamate vivono in `Singe/Framework.singe`. [#_what_you_learned_28] === Cosa hai imparato * Il servizio custodisce un account, un catalogo e le classifiche dei punteggi record. Non è multigiocatore, non è un cloud per i salvataggi e non è mai obbligatorio. * L'account appartiene alla macchina e l'accesso si fa dagli strumenti di servizio del menu. Un gioco non chiede mai una password. * Una classifica è conservata sotto `GAME_ID` in `games.dat`, un UUID che non deve cambiare mai. `singeGetGameId()` lo legge. * `scoreSubmit` mette in coda un punteggio su disco e ritorna subito; la coda sopravvive allo spegnimento della macchina. * `scoreUpdate()` deve girare a ogni fotogramma, altrimenti niente di quello che fa il servizio arriva mai in fondo. * `scoreBoard` consegna la sua risposta a una funzione che scrivi tu, che gira più tardi. Nel frattempo disegna qualcosa. * Ogni chiamata di rete può fallire, e fallire non è un errore. Di' una riga breve e continua a giocare. * Un punteggio inviato è una dichiarazione, e il servizio lo tratta come tale. * Tieni il punteggio record locale. È quello che funziona sempre. [#_next_time_28] === La prossima volta Un punteggio record è un numero solo, e il salvataggio della dodicesima lezione è il posto giusto per lui. La ventinovesima lezione è per tutto il resto che un gioco vuole conservare: ogni round giocato da chiunque, i livelli che distribuisci, e le domande che vuoi fare a entrambi. Prende questo test di reazione e gli dà un database.