singe/docs/fr/lessons/12-score.adoc

558 lines
23 KiB
Text
Raw Permalink 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_12_score_lives_and_game_over]
== Leçon 12 : Score, vies et fin de partie
image::learn/12-score.png[La leçon terminée, 480]
Votre jeu tient le score, retire des vies et affiche GAME OVER. C'est presque un
jeu. Il manque encore trois choses. Il n'a pas de porte d'entrée : il est au
milieu de l'action à l'instant où il se charge. Il oublie : quittez-le, et le
meilleur score jamais obtenu part avec. Et il garde la trace de ce qu'il fait
avec deux variables vrai-ou-faux, `over` et `dying`, ce qui est le piège dont
la leçon sept vous a prévenu.
Cette leçon règle les trois. Une seule variable pour ce que fait le jeu, un
écran titre d'où partir, un meilleur score qui est toujours là le lendemain, et
une deuxième partie qui commence exactement comme la première.
[#_a_game_is_always_in_exactly_one_state]
=== Un jeu est toujours dans exactement un état
Regardez n'importe quelle borne d'arcade pendant deux minutes. Elle affiche un
titre et attend quelqu'un. Quelqu'un appuie sur start et elle joue. Le joueur
n'a plus de vies et elle le dit, puis elle se remet à attendre. La machine fait
toujours exactement une de ces choses.
Dodge, à la leçon sept, gardait cela dans une seule variable qui contenait un
mot : `"waiting"`, `"playing"` ou `"over"`. Le jeu de la leçon neuf est revenu à
un `over` vrai-ou-faux, et la leçon dix a ajouté `dying` à côté. Comptez ce que
deux de ces variables peuvent dire. Chacune vaut `true` ou `false`, donc
ensemble elles font quatre combinaisons. Trois veulent dire quelque chose : la
partie en cours, la mort du vaisseau et la fin de partie. La quatrième, les deux
à la fois, est absurde, et votre code doit prendre soin de ne jamais la
produire. Il en prend soin : regardez `endGame`, qui remet `dying` à `false`
avant de mettre `over` à `true`. Ajoutez maintenant un écran titre. Ça fait un
troisième drapeau, huit combinaisons, dont quatre absurdes. C'est exactement le
piège de la leçon sept, et c'est là que les petits jeux vont mourir.
Alors faites comme Dodge. Quatre états, une seule variable. Mettez les noms des
états sous vos autres constantes :
[source,lua]
----
local TITLE = "title"
local PLAYING = "playing"
local DYING = "dying"
local OVER = "over"
----
Puis retirez `over` et `dying` de vos variables, et mettez une seule ligne à
leur place :
[source,lua]
----
local state = TITLE
----
Dodge se servait des mots eux-mêmes. Des noms en majuscules ne sont pas plus
malins, et ils ne rattrapent pas une faute de frappe : Lua traite un nom mal
tapé comme `nil` et un mot mal tapé comme un mot qui ne correspond à rien, et
l'un comme l'autre vous donne un jeu qui cesse sans bruit de répondre. Ce que
les noms vous apportent, c'est une liste. Chaque état dans lequel le jeu peut se
trouver est écrit une fois, en haut du fichier, là où vous voyez les quatre d'un
coup d'œil.
Partout où le jeu levait un drapeau, il règle maintenant l'état. Dans
`updateRocks`, là où la dernière vie s'en va :
[source,lua]
----
if lives <= 0 then
state = DYING
deathChannel = channel
----
Dans `endGame`, deux lignes n'en font plus qu'une, et la combinaison absurde a
disparu parce qu'il ne reste plus rien à combiner :
[source,lua]
----
function endGame()
deathChannel = -1
state = OVER
musicStop(theme, 1500)
end
----
Et dans `startGame`, les deux lignes qui baissaient les deux drapeaux
deviennent `state = PLAYING`.
[#_one_place_that_decides]
=== Un seul endroit qui décide
À chaque image, `onOverlayUpdate` prend les deux décisions qui comptent : ce qui
bouge, et quels mots vont par-dessus. Ce qui bouge tient en un seul test :
[source,lua]
----
if state == PLAYING then
updateShip()
updateShots()
updateRocks()
end
----
Ça remplace `if not over and not dying then`, et ça dit ce que ça veut dire.
Sur l'écran titre, pendant que joue la dernière explosion, et une fois la partie
finie, rien ne bouge. Vous l'obtenez en n'appelant pas les fonctions qui font
bouger les choses. Arrêter un jeu n'est pas une fonctionnalité spéciale ; c'est
laisser des choses de côté.
Le monde est toujours dessiné à chaque image, quel que soit l'état, exactement
comme Dodge le dessinait. Seuls les mots changent. Remplacez la ligne
`drawHud()` par ceci :
[source,lua]
----
if state == TITLE then
drawTitle()
else
drawHud()
end
----
`drawTitle` est une nouvelle fonction, et elle va après `drawStars` :
[source,lua]
----
function drawTitle()
overlayPrint(25, 6, "R O C K S")
overlayPrint(25, 9, "BEST " .. best)
overlayPrint(22, 12, "PRESS 1 TO PLAY")
end
----
`best` est le meilleur score jusqu'ici, et il arrive à la section suivante ; le
jeu ne tournera pas tant qu'il ne sera pas là. Avant la première partie, la
liste des rochers est vide, donc ce que montre l'écran titre, ce sont les
étoiles qui défilent et le vaisseau qui attend en bas, et c'est tout ce dont un
écran titre a besoin.
Une ligne doit partir, sinon vous ne verrez jamais l'écran titre. Supprimez le
`startGame()` tout en bas du fichier. Le jeu commence maintenant sur son écran
titre, et *Recommencer une partie* explique pourquoi cette ligne compte.
`drawHud` pose lui-même une question. Changez son `if over then` en :
[source,lua]
----
if state == OVER then
----
Le callback des commandes demande le même genre de réflexion. Le voici en
entier :
[source,lua]
----
function onInputPressed(what)
if what == SWITCH_LEFT then
goLeft = true
elseif what == SWITCH_RIGHT then
goRight = true
elseif what == SWITCH_BUTTON1 and state == PLAYING then
shots[#shots + 1] = { x = shipX + shipWidth / 2 - shotWidth / 2, y = shipY }
soundPlay(shootClip, 0, 35)
elseif what == SWITCH_START1 and (state == TITLE or state == OVER) then
soundPlay(clickClip)
startGame()
end
end
----
Lisez-le depuis le haut. Les touches fléchées sont notées quel que soit l'état,
parce qu'elles disent seulement quelles touches sont enfoncées, et rien ne fait
bouger le vaisseau tant que l'état n'est pas `PLAYING`. Le tir ne veut dire
quelque chose que pendant la partie. Et `1` lance une partie depuis l'écran
titre ou depuis l'écran de fin de partie, mais pas pendant que la dernière
explosion résonne encore : la partie n'est pas finie tant qu'elle ne l'a pas
dit. Les parenthèses marchent comme en arithmétique. Le `or` qu'elles
contiennent est calculé d'abord, puis le `and` le relie à la touche.
[#_score_and_lives]
=== Score et vies
Vous avez les deux depuis la leçon neuf, et ils ne changent presque pas. Ce qui
est nouveau, c'est le meilleur score, qui commence sa vie comme une variable
ordinaire. Mettez-le avec les autres, sous `lives` :
[source,lua]
----
local best = 0
----
Affichez-le sur la ligne du haut, entre les deux autres. Dans `drawHud` :
[source,lua]
----
overlayPrint(1, 1, "SCORE " .. score)
overlayPrint(25, 1, "BEST " .. best)
overlayPrint(50, 1, "LIVES " .. lives)
----
Et donnez à `endGame` la tâche de remarquer un record :
[source,lua]
----
if score > best then
best = score
end
----
Lancez-le. L'écran titre affiche `BEST 0`. Appuyez sur `1`, tirez sur quelque
chose, perdez, et l'écran de fin de partie montre votre score comme meilleur
score. Appuyez de nouveau sur `1` : le meilleur score reste, et le score repart
de zéro. Maintenant, quittez, relancez, et regardez l'écran titre : `BEST 0`. Le
record a vécu exactement aussi longtemps que le programme. C'est la deuxième
moitié de cette leçon.
Pendant que vous regardez les vies, remarquez que la leçon neuf a écrit le test
`lives <= 0`, et non `lives == 0`, et gardez-le ainsi. Aujourd'hui, rien ne peut
retirer deux vies à la fois, donc les deux tests donnent forcément la même
réponse. Le jour où vous ajouterez un rocher qui en coûte deux, `== 0` enjambera
zéro et le joueur continuera de jouer avec moins une vie, alors que `<= 0`
continuera de marcher. Demandez ce que vous voulez dire.
[#_starting_again]
=== Recommencer une partie
Vous avez déjà supprimé le `startGame()` du bas du fichier. À la leçon dix, la
première partie était lancée par cette ligne et toutes les suivantes par un
appui sur `1`, ce qui faisait deux entrées. Maintenant, il n'y en a plus qu'une.
Chaque partie, la première comprise, commence quand quelqu'un appuie sur `1` et
que `startGame` s'exécute, donc la première partie et la cinquième commencent
exactement de la même façon.
`startGame` doit remettre en place tout ce qu'une partie change :
[source,lua]
----
function startGame()
shipX = (screenWidth - shipWidth) / 2
score = 0
lives = START_LIVES
state = PLAYING
deathChannel = -1
shots = {}
for i = 1, ROCK_COUNT do
rocks[i] = {}
newRock(rocks[i])
end
musicSetVolume(50)
musicPlay(theme, -1)
end
----
Il est tout aussi important de savoir ce qu'il laisse tranquille, et pourquoi.
`best` survit à toutes les parties ; c'est tout son travail. Les étoiles sont du
décor. Et `goLeft` et `goRight` n'appartiennent pas du tout au jeu. Elles
représentent le clavier, et seul le clavier doit les changer.
Ce dernier point est un bogue que vous venez de corriger sans le remarquer. Le
callback des commandes de la leçon dix commençait par `if over then`, et tant
que la partie était finie, il jetait toutes les touches sauf `1`. Appuyez sur la
flèche droite pendant que GAME OVER est affiché, gardez-la enfoncée, et appuyez
sur `1`. Le nouveau vaisseau reste immobile : l'appui sur la flèche droite a été
jeté, le relâchement n'a pas encore eu lieu, et donc `goRight` vaut `false`
alors que la touche est de toute évidence enfoncée. Le vaisseau ne bouge pas
tant que vous ne lâchez pas la touche pour appuyer de nouveau. Le nouveau
callback note les touches fléchées dans tous les états, donc la touche tenue est
toujours tenue quand la partie commence.
Les bogues de redémarrage sont les bogues les plus courants de tous les jeux, et
ils donnent tous la même impression quand on joue : la deuxième partie est
subtilement fausse alors que la première allait bien. Quand ça arrive, lisez
`startGame` et posez-vous deux questions. Qu'est-ce que la partie précédente a
changé et que celle-ci ne remet pas en place ? Et qu'est-ce que celle-ci remet
en place alors que ce n'était pas au jeu d'y toucher ?
[#_a_number_that_outlives_the_program]
=== Un nombre qui survit au programme
Tout ce que votre jeu a gardé jusqu'ici -- `score`, `lives`, la liste des
rochers -- vit en mémoire, et la mémoire disparaît à l'instant où le programme
s'arrête.
Singe donne à chaque jeu une sauvegarde bien à lui : un ensemble de noms avec
des valeurs rangées dessous, écrit sur le disque pour vous. C'est une autre
partie du kit de jeu de la leçon onze, donc c'est le même genre de chose que
`collideRects` : de simples appels, rien à charger ni à libérer.
[source,lua]
----
saveSet("highScore", 4200)
----
`saveSet` prend un nom -- la *clé* -- et une valeur. La clé, c'est vous qui la
choisissez, et elle ne change jamais ; la valeur, c'est ce dont vous voulez vous
souvenir. Les nombres, les chaînes, `true` et `false`, et des tables entières de
tout cela peuvent y aller. Ce qui n'a de sens que pendant que le programme
tourne, non. Un handle de sprite n'est qu'un nombre, donc il se sauvegarde, et
demain c'est un nombre qui ne veut plus rien dire. Une fonction ne peut pas du
tout s'écrire sur le disque, et essayer d'en sauvegarder une arrête le jeu sur
une erreur plutôt que de vous laisser le découvrir la semaine prochaine.
Relire la valeur, c'est l'autre moitié. Changez la ligne que vous avez ajoutée
pour `best` :
[source,lua]
----
local best = saveGet("highScore", 0)
----
Elle s'exécute une fois, au démarrage du jeu. Le second argument est la *valeur
par défaut* : ce qu'il faut répondre quand rien n'a jamais été sauvegardé sous
cette clé. On y revient dans un instant, parce que c'est toute l'erreur de cette
leçon.
Et faites en sorte que `endGame` note le record quand il tombe :
[source,lua]
----
function endGame()
deathChannel = -1
state = OVER
musicStop(theme, 1500)
if score > best then
best = score
saveSet("highScore", best)
saveFlush()
end
end
----
Vous n'avez pas à demander à Singe d'écrire le fichier. Il l'écrit une fois à la
fin de chaque image où quelque chose a changé, quel que soit le nombre de clés
que vous avez réglées, et de nouveau quand le jeu s'arrête. `saveFlush`, lui,
l'écrit immédiatement : un nouveau meilleur score mérite une écriture de fichier
rien qu'à lui, parce qu'on coupe une borne d'arcade à la prise et qu'une image,
c'est long.
Lancez-le, établissez un score, quittez, et relancez-le. L'écran titre s'en
souvient.
La section Sauvegarde de la Référence donne le reste de la famille. `saveDelete`
oublie une clé, `saveClear` oublie tout -- c'est le « remettre à zéro les
meilleurs scores » d'un menu de service -- et `saveGetAll` vous donne une copie
de l'ensemble, ce qui est utile quand vous voulez afficher toute la sauvegarde
pendant que vous traquez quelque chose.
[#_where_the_file_actually_goes]
=== Où va vraiment le fichier
Il ne va pas à côté de votre script, et ce n'est pas un hasard.
Un jeu fini peut se trouver sur un disque, sur un partage réseau, ou dans un
seul fichier empaqueté (leçon trente), et on ne peut écrire dans aucun d'eux. Un
jeu qui sauvegarde à côté de son propre script marche parfaitement sur la
machine où vous l'avez écrit et échoue sur la moitié des machines où il est
installé. Singe 2 faisait comme ça, et c'était une erreur.
Donc tout ce que Singe écrit pour un jeu -- la sauvegarde, les captures d'écran,
son journal -- va au même endroit, et `singeGetDataPath` vous dira où :
[source,lua]
----
debugPrint(singeGetDataPath())
----
Mettez ça juste sous la ligne `dofile` un instant et lancez-le. Il affiche
`data/rocks/` : le dossier `data` qui est apparu dans votre dossier de travail
la première fois que vous avez lancé quoi que ce soit, à la leçon un, et dedans
un dossier qui porte le nom du dossier de votre jeu. Vous y trouverez `save.json`.
Ouvrez-le dans votre éditeur de texte : c'est du texte simple, et votre meilleur
score s'y trouve, sous le nom que vous lui avez donné.
C'est aussi la réponse chaque fois que vous avez besoin d'un fichier à vous.
Construisez son nom à partir de `singeGetDataPath()` et il atterrit dans un
endroit où l'on peut écrire ; écrivez sous un nom de votre invention et tôt ou
tard ce ne sera plus le cas.
[#_the_first_time_there_is_nothing]
=== La première fois, il n'y a rien
La toute première fois que quelqu'un lance votre jeu, aucun meilleur score n'a
jamais été sauvegardé. `saveGet` n'a rien à renvoyer, alors il renvoie la valeur
par défaut que vous lui avez donnée :
[source,lua]
----
local best = saveGet("highScore", 0)
----
Rien n'est sauvegardé, donc `best` vaut `0`, l'écran titre affiche `BEST 0`, et
la première partie que quelqu'un joue établit un record. C'est exactement ce que
vous voulez, et ça coûte un argument de plus.
Oubliez le `0` et `saveGet` n'a pas de valeur par défaut sur laquelle se
rabattre, alors il répond `nil` -- la valeur dont Lua se sert pour dire « il n'y
a rien ici », que vous avez rencontrée à la leçon trois. `nil` n'est pas zéro.
Vous ne pouvez rien y ajouter, vous ne pouvez pas le comparer à un nombre, et
vous ne pouvez pas le coller à une chaîne. Ça tourne mal dès la première image,
à chaque fois, sur chaque machine où le jeu n'a jamais été joué -- c'est-à-dire
sur chaque machine où vos joueurs s'apprêtent à l'installer, et pas sur celle où
vous testez.
Donnez une valeur par défaut à chaque `saveGet`. C'est l'habitude la moins chère
de ce livre.
[#_what_just_happened_8]
=== Ce qui vient de se passer
[source,lua]
----
local TITLE = "title"
----
Une constante : un nom que vous réglez une fois et ne changez jamais, en
majuscules pour que vous voyiez d'un coup d'œil que c'en est une, comme à la
leçon sept. Lua ne l'impose pas -- rien ne vous empêche d'affecter une valeur à
`TITLE` -- alors les majuscules sont un message pour la prochaine personne qui
lira le code, c'est-à-dire vous dans quinze jours.
[source,lua]
----
if state == PLAYING then
----
La question qui décide si le monde bouge. Quand vous ajouterez un cinquième état
plus tard -- une pause, ou un tour bonus -- la première chose à vous demander
sera si le monde bouge dans cet état, et c'est ici que va la réponse.
[source,lua]
----
elseif what == SWITCH_START1 and (state == TITLE or state == OVER) then
----
Sans les parenthèses, Lua relierait d'abord le `and` et lirait ceci comme « la
touche est `1` et l'état est le titre, ou l'état est la fin de partie ». Alors
n'importe quelle touche que les branches du dessus n'ont pas attrapée lancerait
une partie depuis l'écran de fin de partie, le tir compris, et un joueur qui
martèle encore le bouton de tir quand le dernier rocher le touche serait jeté
dans une nouvelle partie dès l'apparition de GAME OVER. Les parenthèses font
passer le `or` en premier. Quand vous mélangez `and` et `or`, mettez les
parenthèses même si vous croyez connaître l'ordre. La prochaine personne qui
lira la ligne n'aura pas besoin de le connaître.
[source,lua]
----
saveSet("highScore", best)
saveFlush()
----
`saveSet` met le nombre dans la sauvegarde ; `saveFlush` met la sauvegarde sur
le disque tout de suite plutôt qu'à la fin de l'image. Les deux sont dans le
`if`, donc le fichier n'est écrit que quand le record est vraiment battu, ce qui
arrive quelques fois par session plutôt que soixante fois par seconde.
[source,lua]
----
local best = saveGet("highScore", 0)
----
Lu une fois, au démarrage, dans une variable ordinaire. Le reste du jeu lit
`best` au lieu de rappeler `saveGet`, parce que la sauvegarde est un fichier et
qu'une variable est une variable. Écrivez dans les deux quand le record tombe.
[#_try_it_12]
=== Essayez
. *Revenez à l'écran titre.* Une borne d'arcade ne reste pas sur GAME OVER pour
toujours. Cherchez `timerAfter` dans la section Minuteur de la Référence, une
autre partie du kit de jeu, et servez-vous-en dans `endGame` pour remettre
l'état à `TITLE` cinq secondes après la fin de la partie. Puis appuyez vite
sur `1` après la fin d'une partie et vérifiez que le minuteur ne renvoie pas
votre nouvelle partie à l'écran titre au bout de trois secondes.
. *Montrez les vies sous forme de vaisseaux.* Dessinez `shipSprite` une fois
par vie dans le coin supérieur droit avec `spriteDraw` au lieu d'afficher le
nombre. Une boucle `for` de la leçon quatre, et `shipWidth` vous dit à quelle
distance les espacer.
. *Sauvegardez plus qu'un nombre.* Ajoutez `saveSet("gamesPlayed", saveGet("gamesPlayed",
0) + 1)` à `startGame`, et affichez-le sur l'écran titre. Quittez, relancez,
et regardez-le se souvenir.
. *Trouvez le fichier et cassez-le.* Ouvrez `data/rocks/save.json` dans votre
éditeur, remplacez le meilleur score par quelque chose d'absurde, et lancez le
jeu. Puis supprimez complètement le fichier et relancez-le. Ces deux choses
arriveront à vos joueurs.
. *Remettez-le à zéro depuis le jeu.* Faites qu'une touche sur laquelle vous
n'appuieriez jamais par accident appelle `saveClear()`, et regardez l'écran
titre ensuite. Attention : `best` est toujours en mémoire, et `saveClear` n'y
touche pas. Trouvez ce qui doit se passer d'autre.
[#_break_it_on_purpose_11]
=== Cassez-le exprès
Retirez la valeur par défaut, pour que la ligne devienne :
[source,lua]
----
local best = saveGet("highScore")
----
Si vous avez déjà établi un meilleur score, il ne se passe rien du tout, et
c'est là le piège : la sauvegarde a la clé, donc `saveGet` la renvoie. Supprimez
d'abord `data/rocks/save.json`, puis lancez-le. L'écran titre n'apparaît jamais,
et vous obtenez ceci :
----
Error executing function 'onOverlayUpdate': rocks/rocks.singe:101: attempt to concatenate a nil value (upvalue 'best')
stack traceback:
rocks/rocks.singe:101: in function 'drawTitle'
rocks/rocks.singe:165: in function 'onOverlayUpdate'
----
Lisez-le comme la leçon huit vous l'a appris. Le fichier et la ligne vous mènent
à `overlayPrint(25, 9, "BEST " .. best)` dans `drawTitle`, et la plainte dit que
quelque chose qui valait `nil` a été collé à une chaîne. `..` joint deux
chaînes, et `nil` n'en est pas une. Si vos lignes sont disposées autrement, vos
numéros le seront aussi.
La ligne qu'elle nomme n'est pas celle où se trouve l'erreur. La ligne 101 est
l'endroit où le jeu a fini par trébucher dessus ; l'erreur a été commise près du
haut du fichier, là où on a demandé `best` et où il n'y avait rien. Cet écart
entre l'endroit où une mauvaise valeur est fabriquée et celui où on la remarque
est ce qu'il y a de plus difficile avec `nil`, et c'est la raison pour laquelle
une valeur par défaut coûte un argument et épargne une soirée.
Remettez le `0`.
[#_what_you_learned_12]
=== Ce que vous avez appris
* Un jeu est dans exactement un état à la fois, et les drapeaux vrai-ou-faux qui
essaient de dire lequel engendrent des combinaisons qui ne veulent rien dire.
* Gardez l'état dans une seule variable, avec ses valeurs possibles nommées une
fois en haut du fichier.
* `onOverlayUpdate` décide de ce qui bouge et de ce qui est dit, en interrogeant
l'état.
* Arrêter un jeu, c'est simplement ne pas appeler ce qui le fait bouger.
* Faites commencer chaque partie, la première comprise, par une seule fonction,
et faites-lui remettre en place tout ce qu'une partie change -- et rien
d'autre.
* Les touches appartiennent au clavier. Notez-les dans tous les états.
* `saveSet` range une valeur sous une clé, et `saveGet` la relit lors d'un
lancement ultérieur.
* `saveFlush` écrit le fichier tout de suite, pour les choses que vous
détesteriez perdre.
* La sauvegarde va dans le dossier de données propre au jeu, que nomme
`singeGetDataPath`. N'écrivez jamais à côté de votre script.
* `saveGet` sans valeur par défaut répond `nil` quand rien n'a été sauvegardé,
et `nil` casse la première image sur une machine qui n'a jamais lancé votre
jeu.
* Quand vous mélangez `and` et `or`, mettez des parenthèses.
[#_next_time_12]
=== La prochaine fois
Votre jeu est maintenant un jeu, et c'est un dossier que vous seul savez lancer.
La leçon treize lui donne un `games.dat`, pour que le menu du moteur lui-même le
liste avec un titre et une image, et qu'un joueur qui n'a jamais entendu parler
de vous puisse le lancer sans rien taper.