singe/docs/fr/lessons/16-branching.adoc

548 lines
20 KiB
Text
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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