[#_lesson_28_online] == Leçon 28 : En ligne image::learn/28-online.png[La leçon terminée, 480] À la leçon douze, vous avez sauvegardé un meilleur score. Il est toujours là le lendemain matin, ce qui est l'essentiel de ce à quoi sert un meilleur score, mais c'est un meilleur score sur une seule machine. Cette leçon met un score sur un tableau que d'autres peuvent voir. C'est aussi la leçon sur l'échec. Tout ce que vous avez écrit jusqu'ici échoue pour des raisons que vous pouvez trouver : un fichier manque, un nombre est faux, une fonction est mal orthographiée. Le réseau échoue pour des raisons que personne dans la pièce ne peut voir. Un câble a été débranché dans un bâtiment où vous n'avez jamais mis les pieds, et votre jeu doit continuer à tourner. L'essentiel de ce qui suit parle de cela. [#_what_the_service_is] === Ce qu'est le service Singe peut parler à un *service maître*, un serveur qui détient trois choses : * un *compte*, qui appartient à la machine et non à un jeu en particulier ; * un *catalogue* de jeux à télécharger et à mettre à jour ; * des *tableaux des meilleurs scores*, un ou plusieurs par jeu. Le service par défaut est `master.singeengine.com`, et un exploitant qui fait tourner le sien fait pointer la machine ailleurs. La section du manuel sur le service en ligne décrit les deux côtés. Maintenant, la partie que les gens sautent. Le service n'est *pas* du multijoueur : deux joueurs n'ont aucun moyen d'être dans la même partie. Ce n'est pas un nuage de sauvegardes ; la sauvegarde de la leçon douze reste sur la machine. Ce n'est pas une protection contre la copie, et rien en lui n'empêche qui que ce soit de jouer à votre jeu. Il n'est pas obligatoire : un jeu qui ne se connecte jamais tourne exactement comme il l'a toujours fait, et c'est le seul comportement par défaut raisonnable pour quelque chose qui a besoin d'une connexion qui marche. Le tableau est donc un bonus. Gardez le meilleur score local de la leçon douze, continuez à l'afficher, et laissez le tableau en ligne être la chose qui est là de temps en temps. [#_signing_in_happens_once_and_not_in_your_game] === On se connecte une fois, et pas dans votre jeu Le compte appartient à la machine. On le crée et on s'y connecte depuis le menu fourni, derrière la touche affectée à `INPUT_SERVICE`, dans l'outil appelé *Online Account*. Le jeton qu'il obtient est écrit dans `master.dat` à la racine des données, un niveau au-dessus du dossier de données propre à chaque jeu, si bien que tous les jeux de la machine envoient leurs scores sous la même connexion. Votre jeu ne demande jamais d'adresse e-mail et ne demande jamais de mot de passe. Il n'existe aucune fonction pour le faire, et c'est voulu : un joueur qui tape un mot de passe dans un jeu qu'il a téléchargé n'a aucun moyen de savoir où il part. Ce sous quoi un joueur apparaît sur un tableau est un *nom de joueur* : de trois à vingt caractères, lettres, chiffres, traits d'union et tirets bas, qui commencent et finissent par une lettre ou un chiffre. Pas d'espaces, pas de points, et pas de `@`, si bien qu'un nom de joueur ne peut même pas ressembler à une adresse e-mail. Il est séparé du compte, c'est la seule chose d'un joueur que les autres joueurs voient, et il est choisi une fois pour toutes -- un exploitant peut en changer un, un joueur ne le peut pas, parce qu'un tableau dont les noms bougent sans cesse n'est vraiment l'histoire de personne. Tant qu'aucun nom de joueur n'a été choisi, les scores sont refusés, puisqu'un score qui ne porte le nom de personne n'est sur un tableau dans aucun sens utile. L'outil Online Account montre le nom sous lequel cette machine envoie ses scores, et votre jeu le lit avec `scorePlayerName()`, qui répond `nil` quand il n'y en a pas. Bon à savoir avant que cela ne vous intrigue : un score envoyé sans nom de joueur derrière lui est refusé par le *service*, pas par `scoreSubmit`, si bien que votre jeu n'en est pas informé. Si `scorePlayerName()` vaut `nil`, dites-le à l'écran tant qu'il y a encore quelqu'un pour le lire. [#_your_game_needs_an_id] === Votre jeu a besoin d'un identifiant Un tableau doit être rangé sous quelque chose. Ce quelque chose, c'est `GAME_ID`, dans l'entrée de votre jeu dans `games.dat`, le fichier que vous avez créé à la leçon treize : [source,lua] ---- GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10" ---- C'est un UUID, exactement de cette forme : huit chiffres hexadécimaux, trois groupes de quatre, puis douze, séparés par des traits d'union. Générez-en un comme vous voulez et collez-le ; peu importe lequel vous obtenez, l'important est qu'il ne change plus jamais. L'entrée de la Référence pour `singeGetGameId` explique pourquoi rien d'autre n'est accepté. `singeGetGameId()` le renvoie, ou `nil` quand l'entrée n'en a pas. Un jeu sans identifiant peut être joué mais pas classé, et demander d'abord vous évite de proposer un tableau des scores qui ne pourra jamais marcher : [source,lua] ---- if singeGetGameId() then -- showLeaderboardButton est votre propre fonction, pas une de celles de Singe. showLeaderboardButton() end ---- [#_the_six_calls] === Les six appels Tout ce qu'un jeu fait avec le service tient en six fonctions, et elles viennent de `Singe/Framework.singe`, alors le `dofile` en haut de votre script n'est pas facultatif ici : [source,lua] ---- dofile("Singe/Framework.singe") ---- `scoreBegin()` dit qu'une partie commence. `scoreSubmit(value)` met un score en file d'attente. `scoreBoard(onDone)` récupère le tableau. `scorePlayerName()` est le nom de joueur. `scoreWaiting()` est le nombre de scores qui ne sont pas encore partis. Et `scoreUpdate()` doit s'exécuter une fois par image, depuis `onOverlayUpdate`, sinon aucun des autres ne se termine jamais. Ce dernier est toute l'astuce de cette leçon, alors il est dit deux fois. Rien ici ne parle au réseau quand vous l'appelez. `scoreSubmit` écrit dans une file d'attente et rend la main aussitôt. `scoreBoard` retient ce que vous voulez et rend la main aussitôt. Le vrai travail se fait par petites tranches dans `scoreUpdate`, entre vos images, et c'est pour cela que votre jeu ne saccade jamais quand la ligne est lente. Oubliez `scoreUpdate` et rien ne casse, aucune erreur ne s'affiche, et il ne se passe jamais rien. [source,lua] ---- function onOverlayUpdate() scoreUpdate() overlayClear() return OVERLAY_UPDATED end ---- [#_posting_a_score] === Envoyer un score À la fin d'une partie : [source,lua] ---- if scoreSubmit(score) then fetchBoard() else saying = "Kept here only: " .. whyNotPosted() end ---- `scoreSubmit` répond `false` quand il n'y a pas de compte ou que le jeu n'a pas de `GAME_ID`, et `true` quand le score est entré dans la file d'attente. Remarquez ce que le `else` ne fait *pas* : il ne jette pas le score, il ne s'excuse pas longuement, et il n'empêche pas le joueur de commencer une autre partie. Il dit une courte ligne, parce que le joueur n'a rien fait de mal et ne peut rien y corriger d'ici. C'est la file d'attente qui rend cela sûr. Un score en file d'attente est écrit sur le disque, il survit donc à l'extinction de la machine, et il est remis au service la prochaine fois qu'il y a une connexion -- une heure plus tard, ou mardi prochain. Voilà pourquoi une borne dans une salle au wifi médiocre finit quand même sur le tableau. Soyez honnête avec vous-même sur la limite de cette promesse : la file d'attente garde les scores d'une machine qui *a* un compte. Si personne ne s'est jamais connecté, `scoreSubmit` répond `false` et le score n'est pas gardé pour plus tard, parce qu'il n'y a personne pour qui le garder. `scoreWaiting()` vous dit combien il en reste dans la file d'attente, et afficher ce nombre quelque part est une gentillesse : c'est la différence entre « le service est en panne » et « trois de vos scores attendent le retour d'Internet ». `scoreBegin()` mérite un paragraphe à lui seul. Appelez-le quand une partie commence, et le service note le moment d'après sa propre horloge. Le score qui suit porte la durée de la partie, mesurée sur le serveur, et c'est le seul nombre d'un envoi qui n'est pas passé par la machine du joueur. Rien n'est rejeté sur cette base : la durée est montrée à celui qui fait tourner le service, à côté du score, et c'est une personne qui décide. Une machine qui était hors ligne quand la partie a commencé n'enregistre aucune durée, ce qui est honnête plutôt que cassé. Et puisque nous sommes dans l'honnêteté : un score envoyé est une *déclaration*. Le jeu tourne sur l'ordinateur du joueur, alors rien de ce que peut faire le client n'en fait davantage, et une clé secrète livrée dans votre jeu n'aurait que l'apparence de la sécurité. Le service enregistre qui a déclaré quoi, limite la vitesse à laquelle les scores peuvent arriver, et signale les scores aberrants pour qu'une personne les examine. Concevez votre tableau en le sachant. [#_reading_the_board_back] === Relire le tableau `scoreBoard` est la première fonction de ce livre qui prend une fonction comme argument. [source,lua] ---- scoreBoard(function(ok, result) ... end) ---- Vous avez écrit des fonctions à la leçon cinq et vous leur avez donné des noms. Celle-ci n'a pas de nom : elle est écrite là où elle sert, passée directement à `scoreBoard`, et appelée plus tard -- peut-être plusieurs secondes plus tard -- quand la réponse arrive. C'est encore un *callback*, la même idée que `onOverlayUpdate`, sauf que cette fois, c'est vous qui choisissez qui l'appelle. `ok` vaut `true` ou `false`. Quand il vaut `false`, `result` est une courte chaîne qui dit pourquoi. Quand il vaut `true`, `result` est une table en deux parties : * `result.top`, une liste de lignes, chacune avec un `name` et une `value` ; * `result.standing`, la propre `value` de ce joueur, son `rank`, et le nombre de `players` sur le tableau -- qui peut manquer, si ce joueur n'a rien dessus. Entre l'appel et le callback, votre jeu continue de tourner et de dessiner. Ce délai est réel et le joueur le voit, alors mettez-y quelque chose. Le script ci-dessous règle une ligne de texte sur `"Asking the service..."` avant l'appel, et le callback remplace cette ligne soit par le tableau, soit par la raison pour laquelle il n'y en a pas. Un écran qui ne dit rien pendant quatre secondes a l'air cassé ; un écran qui dit ce qu'il fait est juste lent. [#_the_catalogue] === Le catalogue La troisième chose que fait le service, c'est lister des jeux. L'outil *Get Games* du menu montre tout ce que le service propose, marque ce qui est déjà installé sur cette machine, et le télécharge, le met à jour ou le supprime. Un téléchargement est vérifié d'après l'empreinte publiée par le catalogue avant de remplacer quoi que ce soit, si bien qu'un transfert qui arrive abîmé échoue au lieu d'installer un jeu qui ne tournera pas. Un jeu que le service a retiré continue de marcher et apparaît comme installé mais plus proposé : la seule chose qui s'arrête, ce sont les mises à jour, parce que quelqu'un qui a un jeu l'a. Votre jeu n'appelle rien de tout cela, et ne doit pas le faire. Le catalogue vous concerne en tant qu'auteur d'un jeu plutôt qu'en tant que rédacteur d'un script : c'est là que va votre fichier `.game` quand vous le publiez, ce qui est le sujet de la leçon trente, et c'est une raison de plus pour que votre `games.dat` ait un `GAME_ID` qui ne change jamais. Si un jour vous voulez la liste depuis un script, `Singe/Master.singe` est le module qui se trouve sous tout cela, et il vaut la peine d'être lu une fois. [#_what_the_service_knows_about_a_player] === Ce que le service sait d'un joueur Si vous publiez un jeu qui envoie des scores, vous confiez les données de quelqu'un d'autre à un serveur, et c'est votre responsabilité même si vous n'avez pas écrit le serveur. Alors dites-le clairement, dans l'écran « À propos » de votre jeu ou dans son fichier lisez-moi : * Le service détient une *adresse e-mail* et un mot de passe pour le compte. L'adresse sert à récupérer un mot de passe perdu, et à rien d'autre. Elle n'est jamais montrée aux autres joueurs et ne peut pas être changée ensuite. * Le *nom de joueur* est ce qui apparaît sur les tableaux. Il est volontairement séparé du compte pour qu'une adresse ne soit jamais ce que voient les autres joueurs, et il est vérifié d'après une liste de noms que personne ne devrait prendre et de mots qui n'ont pas leur place sur un écran dans une salle publique. * Un *score* porte sa valeur, le tableau où il se trouve, l'heure qu'a enregistrée le service, et le nom de joueur. C'est tout. * Le catalogue sait *quels jeux sont installés* sur la machine, et en quelle version, parce que c'est ainsi qu'il vous propose une mise à jour. Ce que rien de tout cela ne fait, c'est suivre un joueur d'un jeu à l'autre, vendre quoi que ce soit, ou aller chercher dans la machine quoi que ce soit que vous n'avez pas envoyé. Votre jeu ne doit rien ajouter à la liste. Si vous joignez des informations supplémentaires à un score -- `scoreSubmit` prend pour cela un troisième argument facultatif -- qu'elles concernent le jeu. [#_the_script] === Le script Un test de réflexes : attendez le mot, appuyez sur le bouton, marquez ce qui reste de mille. C'est un petit jeu, et tout le reste, c'est la partie en ligne. Créez un dossier appelé `reaction` et mettez ceci dans un fichier `reaction.singe`, dedans. Donnez au dossier un `games.dat` comme celui de la leçon treize, qui désigne `reaction/reaction.singe`, avec dans son entrée la ligne `GAME_ID` vue plus haut. [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 ---- Lancez-le avec `Singe -R reaction`. Sur une machine qui ne s'est jamais connectée, il se joue, il garde votre meilleur score, et il dit `Kept here only: this machine is not signed in.` C'est l'exécution importante, et c'est celle qu'auront la plupart de vos joueurs. [#_what_just_happened_23] === Ce qui vient de se passer [source,lua] ---- local top = nil local standing = nil ---- Deux variables qui valent `nil` la plupart du temps. `nil` veut dire « rien ici », et c'est ce que contient une variable avant qu'on y mette quoi que ce soit. Celles-ci contiennent le tableau quand il y en a un, et `nil` quand il n'y en a pas : pendant que la requête est en route, après un échec, et avant la première partie. Le code de dessin demande `if top then` et ne dessine rien quand il n'y a rien, ce qui est la forme de presque tout le code réseau que vous écrirez. [source,lua] ---- saying = "Asking the service..." scoreBoard(function(ok, result) ---- L'ordre compte. La ligne de texte est réglée *avant* l'appel, parce que l'appel rend la main aussitôt et que le callback peut ne s'exécuter que des secondes plus tard. Si vous ne réglez le texte que dans le callback, l'écran montre le message de la partie précédente jusqu'à ce que la réponse arrive, ce qui est exactement le mauvais message au mauvais moment. [source,lua] ---- if not ok then saying = "No board: " .. tostring(result) return end ---- L'échec vient en premier, et il est court. `tostring` transforme ce qui est revenu en texte, pour que le joindre avec `..` ne puisse pas échouer ; `result` est une chaîne quand `ok` vaut false, mais une ligne de code de dessin qui fait confiance au type de quelque chose reçu d'un serveur est une ligne qui attend de planter. Le joueur voit une phrase, et le jeu continue. [source,lua] ---- if scoreSubmit(score) then fetchBoard() else saying = "Kept here only: " .. whyNotPosted() end ---- Que `scoreSubmit` réponde `false` n'est pas une erreur. C'est un fait concernant cette machine, et les deux faits qu'il peut signifier valent d'être distingués, ce que fait `whyNotPosted` : un `GAME_ID` manquant est *votre* erreur, et une machine non connectée n'est l'erreur de personne. Le meilleur score local a déjà été sauvegardé, deux lignes plus haut, avant toute tentative. Sauvegardez d'abord, envoyez ensuite, toujours. [source,lua] ---- overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting()) ---- La file d'attente, à l'écran. Sur une machine qui a une connexion, cela affiche `0` pour toujours et personne ne le remarque. Sur une machine qui n'en a pas, le nombre monte, et le joueur sait que le jeu garde ses scores au lieu de les avaler. [source,lua] ---- function onOverlayUpdate() scoreUpdate() ---- Première ligne de l'image, avant tout le reste. Mettez-la à un endroit où aucun `if` ne peut la sauter, parce qu'un `scoreUpdate` qui ne s'exécute que sur l'écran de fin de partie, c'est une file d'attente qui ne se vide que sur l'écran de fin de partie. [#_try_it_28] === Essayez . *Débranchez la prise.* Déconnectez la machine du réseau, jouez quelques parties, et regardez le nombre `Waiting to send`. Rebranchez-la et regardez-le descendre sans que vous fassiez quoi que ce soit. . *Supprimez l'identifiant.* Retirez `GAME_ID` de votre `games.dat` et relancez. Le message change. Remettez-le. . *Montrez plus du tableau.* La boucle s'arrête à cinq lignes. L'overlay fait par défaut dix-huit lignes de caractères de haut, alors calculez combien vous pouvez vraiment en faire tenir, et déplacez la ligne `standing` s'il le faut. . *Nommez le tableau.* `scoreSubmit(score, "reaction")` et `scoreBoard(onDone, "reaction")` utilisent un tableau de ce nom au lieu de celui qui porte le nom de votre script. Envoyez vers deux tableaux depuis le même jeu -- par exemple un pour le score et un pour la réaction la plus rapide -- et laissez le bouton 2 choisir lequel est affiché. . *Rendez l'attente visible.* Entre l'appel à `scoreBoard` et l'exécution du callback, dessinez un point qui bouge. Puis découvrez combien de temps cela dure vraiment sur votre connexion, et si ça valait la peine de le dessiner. [#_break_it_on_purpose_27] === Cassez-le exprès Retirez la première ligne de `onOverlayUpdate`, pour que l'appel à `scoreUpdate()` disparaisse, et jouez une partie. Il ne se passe rien. Pas d'erreur, pas de message, pas de tableau, et le nombre `Waiting to send` monte et reste en haut pour toujours. Le jeu se joue parfaitement. C'est la pire sorte de bug, et elle vaut la peine d'être rencontrée une fois exprès : une étape manquante qui produit du silence plutôt qu'une plainte. Il n'y a aucun numéro de ligne à regarder, parce que rien ne s'est mal passé -- vous avez demandé du travail et ne lui avez jamais laissé l'occasion de se faire. Quand quelque chose dans Singe qui prend un callback ne vous rappelle jamais, la première question est toujours de savoir si ce qui le fait avancer s'exécute à chaque image. L'autre, que vous rencontrerez aussi, est plus bruyant. Remettez `scoreUpdate()`, supprimez la ligne `dofile` en haut, et lancez : ---- 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 ligne 89 est le `scoreUpdate()` que vous avez remis, une ligne plus haut maintenant que le `dofile` a disparu. Lisez la première ligne, comme toujours : la fonction que le moteur appelait, le fichier, la ligne et la plainte. Encore `nil` -- le nom `scoreUpdate` ne contient rien, parce que le fichier qui le définit n'a jamais été chargé. Les six appels vivent tous dans `Singe/Framework.singe`. [#_what_you_learned_28] === Ce que vous avez appris * Le service détient un compte, un catalogue et des tableaux des meilleurs scores. Ce n'est pas du multijoueur, ni un nuage de sauvegardes, et il n'est jamais obligatoire. * Le compte appartient à la machine, et on s'y connecte depuis les outils de service du menu. Un jeu ne demande jamais de mot de passe. * Un tableau est rangé sous `GAME_ID` dans `games.dat`, un UUID qui ne doit jamais changer. `singeGetGameId()` le lit. * `scoreSubmit` met un score en file d'attente sur le disque et rend la main aussitôt ; la file d'attente survit à l'extinction de la machine. * `scoreUpdate()` doit s'exécuter à chaque image, sinon rien de ce que fait le service ne se termine jamais. * `scoreBoard` donne sa réponse à une fonction que vous écrivez, et qui s'exécute plus tard. Dessinez quelque chose en attendant. * Chaque appel réseau peut échouer, et échouer n'est pas une erreur. Dites une courte ligne et continuez à jouer. * Un score envoyé est une déclaration, et le service le traite comme tel. * Gardez le meilleur score local. C'est celui qui marche toujours. [#_next_time_28] === La prochaine fois Un meilleur score, c'est un nombre, et la sauvegarde de la leçon douze est le bon endroit pour lui. La leçon vingt-neuf est pour tout le reste de ce qu'un jeu veut garder : chaque manche jouée par qui que ce soit, les niveaux que vous livrez, et les questions que vous voulez poser aux deux. Elle reprend ce test de réflexes et lui donne une base de données.