[#_lesson_16_branching] == Leçon 16 : Les embranchements image::learn/16-branching.png[La leçon terminée, 480] Un jeu vidéo à embranchements est un film avec des bifurcations. Une scène se joue, le joueur choisit, et c'est une autre scène qui se joue ensuite. Faites ça vingt ou trente fois et vous avez un jeu auquel les gens rejoueront pendant des années pour essayer de trouver la fin qu'ils n'ont pas encore vue. Dans cette leçon, vous allez en construire un. Il a quatre scènes, deux choix, un minuteur sur chaque choix, une mort, et trois vies. Le code qui le fait tourner fait une trentaine de lignes, et il en ferait toujours une trentaine si le jeu avait deux cents scènes -- parce que le jeu lui-même ne sera pas du tout écrit en code. [#_imagine_it_is_a_dragon] === Imaginez que c'est un dragon La vidéo que tout le monde a, c'est `Singe/menuBackground.mkv`, les mêmes 420 images que vous pilotez depuis la leçon quatorze. C'est une boule de feu, un dragon en papier, le mot `SINGE`, puis une grille magenta qui défile au loin sous un soleil orange, pour toujours. Ce n'est pas un drame. Il n'y a personne dedans qui soit en danger, et le coucher de soleil se moque bien de ce que vous décidez. Jouez le jeu quand même. Le dragon est assez réel pour deux minutes, et tout ce que vous allez écrire marche exactement pareil quand les images sont celles d'un cascadeur qui tombe d'une falaise. Voici la carte, et les numéros d'image sont ceux que vous avez trouvés vous-même à la leçon quatorze : [cols="1,1,3",options="header"] |=== | Scène | Images | Ce qui se passe | `arrive` | 0 à 119 | Du feu, et le dragon se pose. *Gauche ou droite ?* | `sunset` | 120 à 259 | Il crache du feu. *Se baisser à gauche ou courir à droite ?* | `pit` | 260 à 339 | Vous êtes dévoré. Vous perdez une vie. | `escape` | 340 à 410 | La grille s'éloigne et vous vous en êtes sorti. |=== À gauche, c'est toujours le droit chemin, pour ainsi dire : à gauche depuis `arrive`, on va à `sunset`, à gauche depuis `sunset`, on arrive à `escape`. À droite, c'est le `pit` les deux fois, et pareil si vous mettez trop de temps. [#_the_map_is_data_not_code] === La carte, ce sont des données, pas du code Voici le jeu tout entier, et c'est une table. [source,lua] ---- local scenes = { arrive = { first = 0, last = 119, ask = 30, prompt = "The dragon lands. Left or Right?", left = "sunset", right = "pit", goesTo = "pit" }, sunset = { first = 120, last = 259, ask = 170, prompt = "It breathes fire. Duck Left or run Right?", left = "escape", right = "pit", goesTo = "pit" }, pit = { first = 260, last = 339, death = true, goesTo = "arrive" }, escape = { first = 340, last = 410 } } ---- Une table avec des noms sur la gauche au lieu de numéros, exactement comme à la leçon six, et chaque valeur dedans est une autre table. Chacune de ces tables intérieures est un *enregistrement* : une scène, décrite champ par champ. `scenes.arrive` est la première scène. `scenes.arrive.first` vaut `0`. `scenes.arrive.left` est la chaîne `"sunset"`, qui est le nom d'une autre scène de la même table -- et c'est ainsi que les bifurcations sont notées. Une scène ne contient pas la scène qui la suit. Elle contient le *nom* de la scène qui la suit, et ce nom est cherché le moment venu. Les champs veulent dire ceci : `first` et `last`:: Les images entre lesquelles la scène se déroule. `ask`:: L'image sur laquelle la question apparaît. Le joueur a de `ask` à `last` pour répondre, et c'est ça, le minuteur. `prompt`:: Ce que dit la question. `left` et `right`:: La scène à laquelle mène chaque réponse. `goesTo`:: Où va la scène quand personne n'a choisi -- parce que le joueur n'a plus eu de temps, ou parce qu'il n'y avait pas de question au départ. Pour `pit`, qui est une mort, c'est là que repart la nouvelle tentative. `death`:: Présent, et à `true`, seulement dans une scène qui coûte une vie. Trois choses méritent d'être remarquées à propos de cette table. La première, c'est que c'est le document de conception. Vous pouvez la donner à quelqu'un qui n'a jamais écrit une ligne de Lua, et il saura y lire le jeu. Quand vous voulez que le dragon offre trois chemins, vous ajoutez un champ. Quand vous voulez une autre scène, vous ajoutez un autre enregistrement. Vous n'allez pas chercher le code qui joue les scènes, parce que vous n'y touchez pas. La deuxième, c'est que les enregistrements n'ont pas tous la même forme. `pit` n'a ni `ask`, ni `prompt`, ni `left` -- il n'y a rien à y choisir. `escape` n'a pas de `goesTo`, parce que c'est la fin et qu'elle ne mène nulle part. Un champ qui n'est pas dans une table se lit comme `nil`, le mot que vous avez rencontré à la leçon trois pour « il n'y a rien », et demander `scenes.escape.goesTo` n'est pas une erreur. C'est `nil`, et `nil` est une réponse. Le code qui lit ces enregistrements doit s'y attendre, et dans une minute vous verrez la seule ligne qui s'y attend. La troisième, c'est ce que la table ne contient *pas* : rien dedans ne dessine, ne joue, ni n'attend. C'est une description. Cette séparation, c'est tout le secret de cette leçon. [#_one_function_that_plays_any_scene] === Une fonction qui joue n'importe quelle scène Voici la moitié code, et il n'y a qu'une seule fonction d'une taille notable. Elle est écrite dans le modèle à fil d'exécution de la leçon quinze, parce qu'un jeu à embranchements est la chose la plus séquentielle qui soit. [source,lua] ---- local function playScene(name) local scene = scenes[name] discSkipToFrame(scene.first) while discGetFrame() < scene.last do local frame = discGetFrame() local asking = scene.ask ~= nil and frame >= scene.ask if not asking then answer = nil elseif answer == "left" or answer == "right" then return scene[answer] end drawScene(scene, frame, asking) singeYield() end discPause() if scene.death then lives = lives - 1 if lives == 0 then ending = "The dragon wins. Game over." return nil end waitForGo("Press the space bar to try again.") end return scene.goesTo end ---- Elle prend le nom d'une scène, la joue, et renvoie le nom de la scène qui vient ensuite. C'est tout son contrat, et le jeu tient alors en trois lignes : [source,lua] ---- function singeMain() local scene = "arrive" while scene ~= nil do scene = playScene(scene) end waitForGo(ending .. " Space to quit.") end ---- Commencez à `arrive`. Jouez la scène que vous tenez, quelle qu'elle soit, et gardez ce qu'elle vous rend. Quand elle rend `nil`, il n'y a pas de scène suivante, la boucle s'arrête, et la partie est finie. [#_the_whole_thing_8] === Le tout Créez `dragon.singe` dans votre dossier `movie` et tapez ceci. C'est le plus long script du livre jusqu'ici, et chacun de ses morceaux a été expliqué, sauf les deux petites fonctions utilitaires, qui viennent après. [source,lua] ---- local FPS = 30 local scenes = { arrive = { first = 0, last = 119, ask = 30, prompt = "The dragon lands. Left or Right?", left = "sunset", right = "pit", goesTo = "pit" }, sunset = { first = 120, last = 259, ask = 170, prompt = "It breathes fire. Duck Left or run Right?", left = "escape", right = "pit", goesTo = "pit" }, pit = { first = 260, last = 339, death = true, goesTo = "arrive" }, escape = { first = 340, last = 410 } } local answer = nil local ending = "You got out alive." local lives = 3 local function drawScene(scene, frame, asking) overlayClear() overlayPrint(2, 2, "Lives: " .. lives) if scene.death then overlayPrint(2, 4, "That did not go well.") elseif asking then overlayPrint(2, 4, scene.prompt) overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.") end end local function waitForGo(text) answer = nil while answer ~= "go" do overlayClear() overlayPrint(2, 2, "Lives: " .. lives) overlayPrint(2, 4, text) singeYield() end end local function playScene(name) local scene = scenes[name] discSkipToFrame(scene.first) while discGetFrame() < scene.last do local frame = discGetFrame() local asking = scene.ask ~= nil and frame >= scene.ask if not asking then answer = nil elseif answer == "left" or answer == "right" then return scene[answer] end drawScene(scene, frame, asking) singeYield() end discPause() if scene.death then lives = lives - 1 if lives == 0 then ending = "The dragon wins. Game over." return nil end waitForGo("Press the space bar to try again.") end return scene.goesTo end function onInputPressed(what) if what == SWITCH_LEFT then answer = "left" elseif what == SWITCH_RIGHT then answer = "right" elseif what == SWITCH_BUTTON1 then answer = "go" end end function singeMain() local scene = "arrive" while scene ~= nil do scene = playScene(scene) end waitForGo(ending .. " Space to quit.") end dofile("Singe/Framework.singe") ---- Lancez-le : ---- Singe -R -v Singe/menuBackground.mkv movie/dragon.singe ---- Jouez quatre ou cinq fois. Allez deux fois à gauche et sortez-vous-en. Allez à droite et faites-vous dévorer. Restez les bras croisés et faites-vous dévorer quand même. Perdez vos trois vies. [#_what_just_happened_12] === Ce qui vient de se passer [source,lua] ---- discSkipToFrame(scene.first) while discGetFrame() < scene.last do ---- Une scène, c'est un saut suivi d'une attente, c'est-à-dire le schéma de la leçon quinze, avec les nombres qui sortent de l'enregistrement au lieu d'être tapés. `discSkipToFrame` va à une image et lit à partir de là, et c'est exactement ce qu'est un changement de scène. Le numéro d'image est juste dès l'instant où l'appel se termine, donc le `while` ne croit jamais qu'il est encore dans la scène que vous venez de quitter. [source,lua] ---- local asking = scene.ask ~= nil and frame >= scene.ask ---- La seule ligne qui est prête pour un champ manquant, et la raison pour laquelle la scène de mort peut se passer de `ask`. `and` veut dire que les deux moitiés doivent être vraies, comme à la leçon trois, et Lua vérifie d'abord la moitié gauche. Si `scene.ask` vaut `nil`, la moitié gauche est fausse, Lua s'arrête là, et la comparaison de droite n'a jamais lieu. Et c'est tant mieux, parce que comparer `nil` à un nombre est une erreur, et vous la rencontrerez à la fin de cette leçon. `asking` est la réponse à « la question est-elle à l'écran en ce moment ? », calculée une fois par image et utilisée trois fois. [source,lua] ---- if not asking then answer = nil elseif answer == "left" or answer == "right" then return scene[answer] end ---- Tant que la question n'est pas affichée, tout ce sur quoi le joueur appuie est jeté. Il le faut, sinon un joueur qui reste appuyé sur la flèche gauche pendant le film répond à une question qu'il n'a pas lue. Une fois la question affichée, une réponse termine aussitôt la scène. `return scene[answer]` mérite qu'on s'y arrête : `answer` contient la chaîne `"left"` ou `"right"`, et `scene["left"]` est la même chose que `scene.left`. Mettre le nom entre crochets au lieu de l'écrire après un point vous permet de chercher un champ dont le nom se trouve dans une variable. Deux lignes de code pour les deux réponses, et pour cinq réponses, ce serait toujours deux lignes. Le `elseif` explique aussi pourquoi seuls `"left"` et `"right"` passent. La barre d'espace met `answer` à `"go"`, et `scene["go"]` vaut `nil`, donc un appui égaré sur la barre d'espace pendant une question terminerait la partie en silence. Dites ce que vous acceptez. [source,lua] ---- discPause() ---- Juste après la boucle, et facile à oublier. La scène a atteint sa dernière image mais le disque n'a aucune idée que la scène est finie -- il continue tout droit dans les images qui se trouvent venir ensuite dans le fichier, ce qui, dans un vrai jeu, est le milieu d'une scène venue de tout à fait ailleurs. Arrêtez-le dès que vous n'en voulez plus. [source,lua] ---- if scene.death then lives = lives - 1 ---- `scene.death` vaut `nil` pour toutes les scènes sauf `pit`, et `nil` compte comme faux dans un `if`, donc ceci se lit « si cette scène était une mort ». Vous n'avez pas à écrire `death = false` dans les trois autres enregistrements, et vous ne devriez pas le faire : l'absence du champ le dit tout aussi bien, et il n'y a alors qu'un seul endroit qui décide quelles scènes sont mortelles. La nouvelle tentative, ce sont les deux lignes en dessous. Retirez une vie, et s'il en reste, restez sur l'image et attendez la barre d'espace. Ensuite, `return scene.goesTo` renvoie le joueur à `arrive`, et c'est l'enregistrement qui le sait. Changez `goesTo` en `"sunset"` et la nouvelle tentative devient un point de contrôle au lieu d'un recommencement -- un mot, et la difficulté du jeu change. [source,lua] ---- overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.") ---- Le minuteur. `scene.last - frame` est le nombre d'images qui restent, et diviser par le nombre d'images dans une seconde le transforme en secondes. `math.ceil` arrondit un nombre à l'entier supérieur, donc le compte à rebours affiche `3`, `2`, `1` et jamais `0.4` ni `2.966666`. `FPS` vaut `30` parce que cette vidéo tourne à un peu plus de trente images par seconde. C'est le seul nombre du script qui soit une estimation, et c'est une estimation nommée, en haut du fichier, plutôt qu'un `30` enfoui au milieu d'une ligne, pour que, quand vous changez de vidéo, il y ait exactement une chose à changer. Remarquez par rapport à quoi le minuteur est mesuré : le film. Le joueur fait la course contre la vidéo, pas contre une horloge, donc la question ne peut jamais durer plus longtemps que le plan auquel elle appartient. C'est pour ça que les jeux à embranchements comptent en images. [#_why_not_write_it_out_by_hand] === Pourquoi ne pas tout écrire à la main Vous pourriez écrire ce jeu comme une seule longue fonction. Lire les images 0 à 119 ; si gauche, lire 120 à 259 ; si encore gauche, lire 340 à 410 ; sinon lire 260 à 339, retirer une vie, revenir au début. Pour quatre scènes, ce serait plus court que ce que vous avez. Pour quarante scènes, ce serait une catastrophe, et ça vaut la peine de dire précisément pourquoi. Les numéros d'image seraient éparpillés dans le code, chacun écrit une fois et jamais nommé, donc changer l'endroit où commence une scène voudrait dire retrouver chaque endroit où elle est mentionnée. La forme de l'histoire serait enfouie dans la forme des instructions `if`, donc personne ne pourrait voir le jeu sans tout lire. Et ajouter une scène voudrait dire modifier le code qui joue, c'est-à-dire le code qui marche déjà -- chaque nouvelle scène, une occasion de plus d'en casser une ancienne. La version que vous avez écrite a un morceau de code qui joue les scènes et un morceau de données qui dit ce que sont les scènes. Ajouter une scène ne touche que les données. Ça vaut bien la demi-heure de plus que ça vous a coûté, et c'est la même idée que celle qu'il y a sous le fichier `games.dat` de la leçon treize : décrivez la chose, puis écrivez un seul morceau de code qui lit la description. [#_try_it_16] === Essayez . *Ajoutez une fin.* Donnez à `escape` une question et une deuxième scène de fin qui réutilise des images prises quelque part dans le film. Seule la table change. . *Rendez-le équitable.* Baissez `ask` dans les deux scènes pour que le joueur ait cinq secondes au lieu de trois, puis montez-le pour qu'il n'en ait qu'une. Trouvez le nombre que vous publieriez vraiment. . *Déplacez le point de contrôle.* Changez le `goesTo` de `pit` en `"sunset"` et jouez. Puis réfléchissez à ce qui se passe si un joueur meurt dans `arrive` -- et demandez-vous si la scène de mort devrait savoir d'où elle vient plutôt que de se le faire dire. . *Comptez ce que le joueur a vu.* Ajoutez à côté de `scenes` une table qui note quels noms de scène ont été joués, et affichez leur nombre dans le coin. Vous avez maintenant un jeu qui peut dire « vous avez trouvé trois des cinq fins ». . *Cassez la carte.* Changez le `left` de `arrive` en `"cave"`, une scène qui n'existe pas. Lancez, allez à gauche, et lisez ce que dit Lua. Cette erreur est la plus courante dans un jeu piloté par les données, et connaître sa forme fait gagner une heure. [#_break_it_on_purpose_15] === Cassez-le exprès Retirez le garde-fou de la ligne `asking`, pour qu'elle devienne : [source,lua] ---- local asking = frame >= scene.ask ---- Lancez et allez à droite. La scène `arrive` se joue très bien, parce que `arrive` a un `ask`. Puis la scène `pit` commence, qui n'en a pas, et le jeu s'arrête avec ceci : ---- Error executing function 'onOverlayUpdate': movie/dragon.singe:68: attempt to compare nil with number stack traceback: [C]: in function 'error' Singe/Framework.singe:603: in function 'onOverlayUpdate' ---- Lisez-le comme la leçon un vous l'a appris. Le fichier et la ligne viennent en premier, et ils sont parfaitement justes : la ligne 68 est celle que vous venez de modifier. La plainte, c'est le reste : vous avez demandé à Lua si un nombre était plus grand que `nil`, et Lua n'en a aucune idée. `scene.ask` vaut `nil` parce que l'enregistrement `pit` n'a pas de champ `ask`, et un champ qui n'est pas là vaut `nil`. Une partie de ce message mérite une explication, parce que c'est un mensonge par omission. La fonction nommée est `onOverlayUpdate`, que vous n'avez pas écrite. C'est celle du framework, celle dont la leçon quinze disait qu'elle était installée pour faire tourner votre `singeMain`. L'erreur est passée par elle, donc c'est le nom que le moteur avait sous la main, et le traceback en dessous -- la liste des fonctions qui étaient en train de s'exécuter, la plus intérieure en premier -- ne peut montrer que le framework qui fait suivre votre erreur. Ignorez les deux. Le fichier et la ligne du haut sont à vous, et ce sont eux qui disent vrai. [#_what_you_learned_16] === Ce que vous avez appris * Un jeu à embranchements est un ensemble de scènes, dont chacune nomme les scènes qui viennent après elle. * Gardez les scènes sous forme de données -- une table d'enregistrements -- et le code qui les joue reste une seule fonction, quelle que soit la taille du jeu. * Un enregistrement contient le nom de la scène suivante, pas la scène, et ce nom est cherché quand on en a besoin. * Un champ qui n'est pas dans un enregistrement vaut `nil`, ce qui n'est pas une erreur, et le code qui lit des enregistrements doit s'y attendre. * `scene[answer]` cherche le champ dont le nom est dans une variable ; `scene.left` et `scene["left"]` sont le même champ. * Réglez le minuteur sur la vidéo : un choix dure d'une image à une autre, donc il ne peut jamais durer plus longtemps que son propre plan. * Jetez tout ce sur quoi le joueur a appuyé avant que la question apparaisse. * Mettez le disque en pause dès qu'une scène se termine, sinon il continue dans les images de la scène suivante. * Un champ absent qui tient lieu de faux -- `death` dans trois enregistrements sur quatre -- garde la description courte et honnête. * Comparer `nil` avec un nombre est une erreur, et `and` est la façon de l'éviter. [#_next_time_16] === La prochaine fois Votre jeu demande gauche ou droite. Les machines d'arcade où ce genre de jeu a grandi demandaient quelque chose de plus difficile : visez la chose à l'écran et tirez-lui dessus. La leçon dix-sept parle des pistolets optiques -- l'endroit où le joueur vise, ce que le jeu en fait, et le calibrage dont une vraie borne ne peut pas se passer.