877 lines
30 KiB
Text
877 lines
30 KiB
Text
[#_lesson_7_a_game]
|
||
== Leçon 7 : Un jeu
|
||
|
||
image::learn/07-a-game.png[La leçon terminée, 480]
|
||
|
||
Il y a six leçons, vous avez mis un mot sur un écran noir. Depuis, vous avez
|
||
appris toutes les idées dont est fait un petit jeu : les variables, les
|
||
décisions, les boucles, vos propres fonctions, et les listes de choses. Cette
|
||
leçon n'enseigne aucune idée nouvelle. Elle dépense celles que vous avez.
|
||
|
||
Vous allez construire un jeu appelé Dodge. Des blocs tombent du ciel, vous
|
||
faites glisser une barre le long du bas de l'écran pour les esquiver, et chaque
|
||
bloc qui vous rate vaut un point. Prenez-en trois et c'est fini. Vous le
|
||
construirez en six étapes, et chacune d'elles tourne : vous n'aurez jamais à
|
||
taper cent lignes en croisant les doigts.
|
||
|
||
[#_make_the_folder]
|
||
=== Créez le dossier
|
||
|
||
Créez un dossier appelé `dodge`, et dedans un fichier appelé `dodge.singe`.
|
||
Lancez-le comme vous lancez tout depuis la leçon un :
|
||
|
||
----
|
||
Singe -R dodge
|
||
----
|
||
|
||
Laissez-le tourner dans un terminal pendant toute la leçon. Chaque fois que vous
|
||
enregistrez, le jeu redémarre avec votre modification.
|
||
|
||
[#_stage_one_something_you_can_steer]
|
||
=== Première étape : quelque chose à diriger
|
||
|
||
Tapez ceci, enregistrez, et dirigez-le avec les touches fléchées gauche et
|
||
droite.
|
||
|
||
[source,lua]
|
||
----
|
||
dofile("Singe/Framework.singe")
|
||
|
||
local PLAYER_WIDTH = 40
|
||
local PLAYER_HEIGHT = 8
|
||
local PLAYER_SPEED = 4
|
||
local PLAYER_MARGIN = 4
|
||
|
||
local screenWidth = overlayGetWidth()
|
||
local screenHeight = overlayGetHeight()
|
||
local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN
|
||
|
||
local playerX = (screenWidth - PLAYER_WIDTH) / 2
|
||
local goingLeft = false
|
||
local goingRight = false
|
||
|
||
|
||
function onInputPressed(what)
|
||
if what == SWITCH_LEFT then
|
||
goingLeft = true
|
||
elseif what == SWITCH_RIGHT then
|
||
goingRight = true
|
||
end
|
||
end
|
||
|
||
|
||
function onInputReleased(what)
|
||
if what == SWITCH_LEFT then
|
||
goingLeft = false
|
||
elseif what == SWITCH_RIGHT then
|
||
goingRight = false
|
||
end
|
||
end
|
||
|
||
|
||
function onOverlayUpdate()
|
||
if goingLeft then
|
||
playerX = playerX - PLAYER_SPEED
|
||
end
|
||
if goingRight then
|
||
playerX = playerX + PLAYER_SPEED
|
||
end
|
||
if playerX < 0 then
|
||
playerX = 0
|
||
elseif playerX > screenWidth - PLAYER_WIDTH then
|
||
playerX = screenWidth - PLAYER_WIDTH
|
||
end
|
||
|
||
overlayClear()
|
||
colorForeground(80, 255, 160)
|
||
overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1)
|
||
return OVERLAY_UPDATED
|
||
end
|
||
----
|
||
|
||
Cinquante lignes, et presque rien de nouveau. Il y a deux choses nouvelles,
|
||
alors commençons par la première ligne.
|
||
|
||
[source,lua]
|
||
----
|
||
dofile("Singe/Framework.singe")
|
||
----
|
||
|
||
`dofile` appartient à Lua plutôt qu'à Singe, et ça veut dire « va exécuter ce
|
||
fichier, puis reviens continuer ici ». Le fichier qu'il exécute est
|
||
`Singe/Framework.singe`, l'un des fichiers que le moteur a extraits dans votre
|
||
dossier de travail la première fois que vous avez lancé quoi que ce soit, à la
|
||
leçon un. Ouvrez-le dans votre éditeur de texte et regardez : c'est du Lua
|
||
ordinaire, de la même nature que ce que vous écrivez depuis la leçon deux, et il
|
||
n'a rien de magique.
|
||
|
||
L'exécuter donne à votre script une série de noms dont il peut ensuite se
|
||
servir. Ceux auxquels ce livre fait appel sont les suivants :
|
||
|
||
* `DIR`, qui contient le dossier où se trouve votre propre script. La leçon neuf
|
||
en a besoin dès qu'un jeu charge une image, et c'est la vraie raison pour
|
||
laquelle cette ligne est là.
|
||
* `SCANCODE` et `MODIFIER`, des noms pour chaque touche d'un clavier, pour un
|
||
jeu qui lit ce qu'on tape plutôt qu'un joystick.
|
||
* `GAMEPAD_0` à `GAMEPAD_3` et `MOUSE_0` à `MOUSE_3`, pour une borne sur
|
||
laquelle on a branché plus d'un exemplaire de quelque chose.
|
||
* Quelques fonctions utilitaires dont le nom commence par `util`, et les six
|
||
appels qui envoient un score à un tableau des scores, ce qui est l'objet de la
|
||
leçon vingt-huit.
|
||
* Une seconde façon d'écrire un jeu, tout entière, qui est l'objet de la leçon
|
||
quinze.
|
||
|
||
Une chose qu'il ne vous donne *pas*, parce que c'est ce qu'on croirait
|
||
naturellement. `SWITCH_LEFT`, et tous les autres noms qui commencent par
|
||
`SWITCH_`, viennent du moteur lui-même, avant même qu'un script ne s'exécute.
|
||
C'est pourquoi les leçons trois à six pouvaient lire les touches fléchées sans
|
||
rien charger. Le framework ajoute à ce qui est déjà là ; il ne le fournit pas.
|
||
|
||
Ce jeu-ci n'utilise rien du framework. La ligne est là parce que chaque jeu de
|
||
la leçon neuf jusqu'à la fin du livre en a besoin, et parce que c'est ainsi que
|
||
commence presque chaque vrai jeu Singe. Elle est lue une fois, au démarrage,
|
||
puis on n'y pense plus.
|
||
|
||
La deuxième chose nouvelle, c'est la façon dont les touches fléchées sont
|
||
gérées, parce que c'est la première chose sur laquelle les débutants se
|
||
trompent.
|
||
|
||
`onInputPressed` ne déplace pas le joueur. Il met `goingLeft` à `true`, et
|
||
`onInputReleased` le remet à `false`. Le déplacement se fait dans
|
||
`onOverlayUpdate`, qui demande « le joueur garde-t-il la gauche enfoncée ? »
|
||
soixante fois par seconde. Si vous déplaciez plutôt le joueur dans
|
||
`onInputPressed`, vous obtiendriez un pas par appui : Singe envoie un appui
|
||
exactement une fois, quand la touche s'enfonce, et ne le répète jamais, ce que
|
||
l'entrée de `onInputPressed` dans la Référence dit clairement. Garder la touche
|
||
enfoncée ne ferait rien du tout.
|
||
|
||
Le gestionnaire d'appui note donc ce qui est vrai dans le monde, et la mise à
|
||
jour décide quoi en faire. Gardez cette séparation, et votre code de lecture des
|
||
commandes fera cinq lignes pour toujours.
|
||
|
||
Les quatre noms en majuscules sont des variables ordinaires. Écrire
|
||
`PLAYER_SPEED` en majuscules est un message à vous-même et à quiconque vous lit :
|
||
celui-là est fixé une fois en haut et ne change jamais. Rien dans Lua ne
|
||
l'impose. Ça compte parce que dans une minute il y en aura dix, et quand le jeu
|
||
sera trop difficile, vous voudrez un endroit évident où aller baisser un nombre.
|
||
|
||
`screenWidth` et `screenHeight` viennent du moteur au lieu d'être tapés à la
|
||
main, si bien que le jeu se met en page tout seul, quelle que soit la taille de
|
||
l'overlay. `playerY` est calculé une fois à partir de la hauteur : la barre se
|
||
tient à `PLAYER_MARGIN` pixels du bas et ne monte ni ne descend jamais, alors il
|
||
n'y a aucune raison de le recalculer à chaque image.
|
||
|
||
Le `- 1` de la ligne `overlayBox` n'est pas une faute de frappe. `overlayBox`
|
||
dessine en incluant les deux coins que vous lui donnez, donc une boîte de
|
||
`playerX` à `playerX + 40` fait quarante et un pixels de large. Retirer un donne
|
||
une barre visible d'exactement quarante de large, ce qui comptera à la
|
||
quatrième étape, quand quelque chose devra décider si elle vous a touché.
|
||
|
||
[#_stage_two_something_to_dodge]
|
||
=== Deuxième étape : quelque chose à éviter
|
||
|
||
Un bloc, qui tombe. Ajoutez deux constantes à la liste du haut :
|
||
|
||
[source,lua]
|
||
----
|
||
local BLOCK_SIZE = 12
|
||
local BLOCK_SPEED = 1.5
|
||
----
|
||
|
||
Deux variables de plus sous `goingRight` :
|
||
|
||
[source,lua]
|
||
----
|
||
local blockX = math.random(0, screenWidth - BLOCK_SIZE)
|
||
local blockY = -BLOCK_SIZE
|
||
----
|
||
|
||
Dans `onOverlayUpdate`, juste au-dessus de la ligne `overlayClear()`,
|
||
faites-le tomber :
|
||
|
||
[source,lua]
|
||
----
|
||
blockY = blockY + BLOCK_SPEED
|
||
if blockY > screenHeight then
|
||
blockX = math.random(0, screenWidth - BLOCK_SIZE)
|
||
blockY = -BLOCK_SIZE
|
||
end
|
||
----
|
||
|
||
Et sous la ligne qui dessine le joueur, dessinez-le :
|
||
|
||
[source,lua]
|
||
----
|
||
colorForeground(255, 90, 90)
|
||
overlayBox(blockX, blockY, blockX + BLOCK_SIZE - 1, blockY + BLOCK_SIZE - 1)
|
||
----
|
||
|
||
Enregistrez. Un carré rouge tombe, atteint le bas, et réapparaît en haut à un
|
||
nouvel endroit.
|
||
|
||
Il démarre à `-BLOCK_SIZE`, c'est-à-dire au-dessus du bord supérieur de
|
||
l'écran. Une position négative n'a rien d'anormal : le moteur dessine la partie
|
||
de la boîte qui est sur l'overlay et jette sans rien dire la partie qui n'y est
|
||
pas. Le bloc glisse dans le champ au lieu de surgir de nulle part.
|
||
|
||
`BLOCK_SPEED` vaut `1.5`, pas un nombre entier, donc `blockY` passe la plus
|
||
grande partie de sa vie à valoir quelque chose comme `83.5`. Ça aussi, c'est
|
||
normal. Le dessin laisse tomber la partie fractionnaire, et la garder dans la
|
||
variable est ce qui permet au bloc d'avancer de moins d'un pixel par image.
|
||
|
||
[#_stage_three_a_sky_full_of_them]
|
||
=== Troisième étape : le ciel en est plein
|
||
|
||
Un bloc, ce n'est pas un jeu. À la leçon six, vous avez appris à garder beaucoup
|
||
d'exemplaires de quelque chose dans une liste, et c'est à ça que ça servait.
|
||
|
||
Supprimez `blockX` et `blockY`, et mettez ceci à leur place :
|
||
|
||
[source,lua]
|
||
----
|
||
local blocks = {}
|
||
local spawnTimer = 0
|
||
----
|
||
|
||
Ajoutez une constante de plus en haut :
|
||
|
||
[source,lua]
|
||
----
|
||
local SPAWN_FRAMES = 40
|
||
----
|
||
|
||
Maintenant, une fonction qui fabrique un bloc et le met dans la liste.
|
||
Mettez-la au-dessus de `onInputPressed` :
|
||
|
||
[source,lua]
|
||
----
|
||
function spawnBlock()
|
||
local block = {}
|
||
block.x = math.random(0, screenWidth - BLOCK_SIZE)
|
||
block.y = -BLOCK_SIZE
|
||
table.insert(blocks, block)
|
||
end
|
||
----
|
||
|
||
Remplacez le code de chute de la deuxième étape par ceci :
|
||
|
||
[source,lua]
|
||
----
|
||
spawnTimer = spawnTimer - 1
|
||
if spawnTimer <= 0 then
|
||
spawnBlock()
|
||
spawnTimer = SPAWN_FRAMES
|
||
end
|
||
|
||
for i = #blocks, 1, -1 do
|
||
local block = blocks[i]
|
||
block.y = block.y + BLOCK_SPEED
|
||
if block.y > screenHeight then
|
||
table.remove(blocks, i)
|
||
end
|
||
end
|
||
----
|
||
|
||
Et remplacez la ligne qui dessinait le bloc par une boucle sur tous les blocs :
|
||
|
||
[source,lua]
|
||
----
|
||
for i, block in ipairs(blocks) do
|
||
overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1)
|
||
end
|
||
----
|
||
|
||
Enregistrez. Il pleut des blocs.
|
||
|
||
Un bloc est un enregistrement : une petite table avec un `x` et un `y` dedans,
|
||
ce qui est la manière d'empêcher deux nombres qui vont ensemble de se séparer.
|
||
`blocks` est une liste de ces enregistrements, et c'est la forme qu'a le monde
|
||
de presque tous les jeux.
|
||
|
||
`spawnTimer` décompte d'un par image, et quand il atteint zéro, un bloc naît et
|
||
le minuteur est remonté. Quarante images, c'est environ deux tiers de seconde.
|
||
Il n'y a pas d'horloge dans ce jeu, et il n'en faut pas : `onOverlayUpdate` est
|
||
le battement de cœur, et compter les battements est une très bonne manière de
|
||
mesurer le temps.
|
||
|
||
La boucle de chute tourne *à rebours* -- de `#blocks` jusqu'à `1`, un pas à la
|
||
fois -- parce qu'elle retire des choses en chemin. C'est la règle de la leçon
|
||
six, et c'est la première fois que vous avez une vraie raison de la suivre.
|
||
Retirez l'élément trois en avançant et tout recule d'un cran, si bien que le
|
||
bloc qui était l'élément quatre devient l'élément trois et que la boucle ne le
|
||
regarde jamais. En allant à rebours, tout ce qui bouge a déjà été traité.
|
||
|
||
La boucle de dessin utilise `ipairs` à la place, parce qu'elle ne retire rien
|
||
et veut seulement chaque bloc à tour de rôle.
|
||
|
||
[#_stage_four_getting_hit]
|
||
=== Quatrième étape : se faire toucher
|
||
|
||
Les blocs vous traversent. Il est temps de s'en apercevoir.
|
||
|
||
Le moteur a une fonction pour exactement cette question. Changez le test à
|
||
l'intérieur de la boucle de chute, de ceci :
|
||
|
||
[source,lua]
|
||
----
|
||
if block.y > screenHeight then
|
||
table.remove(blocks, i)
|
||
end
|
||
----
|
||
|
||
à ceci :
|
||
|
||
[source,lua]
|
||
----
|
||
if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) or block.y > screenHeight then
|
||
table.remove(blocks, i)
|
||
end
|
||
----
|
||
|
||
Enregistrez, et lancez la barre contre un bloc qui tombe. Il disparaît.
|
||
|
||
`collideRects` prend deux rectangles et répond `true` s'ils se chevauchent.
|
||
Chaque rectangle fait quatre nombres : un coin, puis une largeur et une
|
||
hauteur -- *pas* deux coins, ce qui est l'autre manière raisonnable de décrire
|
||
un rectangle et celle qu'utilise `overlayBox`. L'entrée de `collideRects` dans
|
||
la Référence donne l'ordre, et ça vaut la peine d'y jeter un œil maintenant, parce
|
||
que mettre les huit arguments dans le mauvais ordre est le genre d'erreur qui ne
|
||
produit aucun message, juste un jeu où rien ne touche jamais rien.
|
||
|
||
Vous pourriez l'écrire vous-même. C'est quatre comparaisons, et ce n'est
|
||
vraiment pas difficile. Servez-vous quand même de celle du moteur, pour trois
|
||
raisons. Elle est juste, y compris dans les cas délicats : un rectangle sans
|
||
largeur, deux rectangles qui ne se touchent que par un bord. Elle dit ce qu'elle
|
||
veut dire, donc la ligne se lit « ces deux-là se sont-ils chevauchés » au lieu
|
||
de quatre comparaisons reliées par `and` qu'il faut déchiffrer chaque fois que
|
||
vous y revenez. Et quand vous arriverez à la leçon onze, où les choses se
|
||
heurtent sans arrêt, vous connaîtrez déjà la famille : `collidePointRect` pour
|
||
un clic de souris, `collideCircles` pour deux choses rondes, et
|
||
`collideSegments` pour un tir contre un mur. La Référence les présente ensemble.
|
||
|
||
La seule chose que le moteur ne peut pas faire pour vous, c'est décider ce
|
||
qu'un rectangle *veut dire*. Ici, le rectangle du bloc est le bloc entier. Dans
|
||
un vrai jeu, vous voulez souvent que le rectangle de collision soit un peu plus
|
||
petit que l'image, pour qu'un frôlement ressemble à un frôlement. C'est une
|
||
décision de conception, et c'est à la leçon onze que vous la prendrez.
|
||
|
||
[#_stage_five_score_and_lives]
|
||
=== Cinquième étape : score et vies
|
||
|
||
Se faire toucher devrait coûter quelque chose, et survivre devrait rapporter
|
||
quelque chose.
|
||
|
||
Deux constantes de plus en haut :
|
||
|
||
[source,lua]
|
||
----
|
||
local BLOCK_FASTER = 0.05
|
||
local START_LIVES = 3
|
||
----
|
||
|
||
Trois variables de plus, à côté de `spawnTimer` :
|
||
|
||
[source,lua]
|
||
----
|
||
local blockSpeed = BLOCK_SPEED
|
||
local score = 0
|
||
local lives = START_LIVES
|
||
----
|
||
|
||
`BLOCK_SPEED` est maintenant la vitesse à laquelle le jeu *démarre*, et
|
||
`blockSpeed` la vitesse à laquelle il va en ce moment. Changez la ligne de chute
|
||
pour qu'elle utilise la variable :
|
||
|
||
[source,lua]
|
||
----
|
||
block.y = block.y + blockSpeed
|
||
----
|
||
|
||
Puis séparez les deux cas, parce qu'ils font maintenant des choses
|
||
différentes :
|
||
|
||
[source,lua]
|
||
----
|
||
if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then
|
||
table.remove(blocks, i)
|
||
lives = lives - 1
|
||
elseif block.y > screenHeight then
|
||
table.remove(blocks, i)
|
||
score = score + 1
|
||
blockSpeed = blockSpeed + BLOCK_FASTER
|
||
end
|
||
----
|
||
|
||
Et affichez les nombres. Mettez ceci juste avant le `return` :
|
||
|
||
[source,lua]
|
||
----
|
||
overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives)
|
||
----
|
||
|
||
Enregistrez, et jouez une minute. Ça devient plus difficile, et c'est tout le
|
||
truc : chaque bloc que vous esquivez fait tomber les suivants plus vite, d'un
|
||
vingtième de pixel par image. Au bout de vingt points, ils sont nettement plus
|
||
rapides. Vous n'avez pas eu à écrire un seul mot sur les niveaux de difficulté.
|
||
|
||
Vous verrez aussi `LIVES` descendre à `-4`, parce que rien n'arrête encore le
|
||
jeu. C'est l'étape suivante, et c'est celle qui compte le plus.
|
||
|
||
[#_stage_six_game_over_and_going_again]
|
||
=== Sixième étape : fin de partie, et on recommence
|
||
|
||
Voici le piège. La manière évidente d'ajouter un écran de fin de partie, c'est
|
||
une variable appelée `gameOver`, mise à `true` quand il n'y a plus de vies. Puis
|
||
vous voulez un écran titre avant la première partie, alors vous ajoutez
|
||
`started`. Puis vous voulez une touche de pause, alors vous ajoutez `paused`.
|
||
Vous avez maintenant trois variables vrai-ou-faux, huit combinaisons entre
|
||
elles, et cinq de ces combinaisons sont des absurdités que votre code doit
|
||
prendre soin de ne jamais produire. Chaque débutant écrit ce jeu au moins une
|
||
fois, et c'est là que les petits jeux vont mourir.
|
||
|
||
Faites plutôt ceci. Une seule variable contient *ce que fait le jeu*, sous forme
|
||
de mot :
|
||
|
||
[source,lua]
|
||
----
|
||
local state = "waiting"
|
||
----
|
||
|
||
Elle vaut `"waiting"` avant la première partie, `"playing"` pendant une partie,
|
||
et `"over"` quand les vies sont épuisées. Trois états, une variable, et rien qui
|
||
ressemble à une combinaison impossible. Ces chaînes n'ont rien de magique ; ce
|
||
sont des mots que vous avez choisis, et vous devez les écrire de la même façon à
|
||
chaque fois.
|
||
|
||
Maintenant, la fonction qui commence une partie. Chaque variable qui appartient
|
||
à une partie revient ici à sa valeur de départ, en un seul endroit :
|
||
|
||
[source,lua]
|
||
----
|
||
function startGame()
|
||
playerX = (screenWidth - PLAYER_WIDTH) / 2
|
||
blocks = {}
|
||
blockSpeed = BLOCK_SPEED
|
||
spawnTimer = 0
|
||
score = 0
|
||
lives = START_LIVES
|
||
state = "playing"
|
||
end
|
||
----
|
||
|
||
Perdre la dernière vie y met fin. Dans la branche de la collision, sous
|
||
`lives = lives - 1` :
|
||
|
||
[source,lua]
|
||
----
|
||
if lives == 0 then
|
||
state = "over"
|
||
end
|
||
----
|
||
|
||
La barre d'espace lance une partie, mais seulement quand il n'y en a pas déjà
|
||
une en cours. Ajoutez une branche à `onInputPressed` :
|
||
|
||
[source,lua]
|
||
----
|
||
elseif what == SWITCH_BUTTON1 and state ~= "playing" then
|
||
startGame()
|
||
----
|
||
|
||
`~=` veut dire « n'est pas égal à ». `state ~= "playing"` est vrai pendant
|
||
l'attente et vrai après la fin, ce qui correspond exactement aux deux moments
|
||
où Espace doit lancer une partie. `SWITCH_BUTTON1` est la barre d'espace par
|
||
défaut, et aussi le bouton A d'une manette.
|
||
|
||
Deux petites fonctions mettent les mots à l'écran. `printCentered` fait le
|
||
calcul une fois, pour que l'écran de titre et celui de fin de partie ne le
|
||
fassent pas chacun de travers :
|
||
|
||
[source,lua]
|
||
----
|
||
function drawOverText()
|
||
printCentered(7, "GAME OVER")
|
||
printCentered(9, "PRESS SPACE TO PLAY AGAIN")
|
||
end
|
||
|
||
|
||
function drawWaitingText()
|
||
printCentered(6, "DODGE THE BLOCKS")
|
||
printCentered(8, "ARROW KEYS TO MOVE")
|
||
printCentered(10, "PRESS SPACE TO START")
|
||
end
|
||
|
||
|
||
function printCentered(row, text)
|
||
overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text)
|
||
end
|
||
----
|
||
|
||
avec une constante de plus en haut :
|
||
|
||
[source,lua]
|
||
----
|
||
local TEXT_COLUMNS = overlayGetWidth() // overlayGetFontWidth()
|
||
----
|
||
|
||
Enfin, la partie importante. Déplacez tout ce qui dessine hors de
|
||
`onOverlayUpdate`, dans une fonction appelée `drawGame`, déplacez tout ce qui
|
||
bouge dans une fonction appelée `updatePlaying` -- toutes les deux sont dans le
|
||
code complet ci-dessous -- et laissez `onOverlayUpdate` ainsi :
|
||
|
||
[source,lua]
|
||
----
|
||
function onOverlayUpdate()
|
||
if state == "playing" then
|
||
updatePlaying()
|
||
end
|
||
|
||
overlayClear()
|
||
drawGame()
|
||
if state == "waiting" then
|
||
drawWaitingText()
|
||
elseif state == "over" then
|
||
drawOverText()
|
||
end
|
||
|
||
return OVERLAY_UPDATED
|
||
end
|
||
----
|
||
|
||
Enregistrez. Vous avez un jeu : un écran titre, une partie, une fin de partie,
|
||
et un moyen de recommencer.
|
||
|
||
Relisez ces treize lignes, parce qu'elles ont la forme de tous les jeux que vous
|
||
écrirez. Le monde ne bouge que tant que l'état vaut `"playing"`, donc l'écran de
|
||
fin de partie se fige avec les derniers blocs suspendus exactement là où ils
|
||
étaient. Le monde est dessiné à chaque image quel que soit l'état, parce que
|
||
l'image est la même dans les trois ; l'état décide seulement quels mots vont
|
||
par-dessus. Et il y a exactement un endroit qui prend cette décision.
|
||
|
||
C'est ce que vous a acheté la variable `state`. Quand vous ajouterez une pause,
|
||
vous ajouterez une branche à ce `if` et une ligne à `onInputPressed`, et rien
|
||
d'autre dans le jeu n'aura besoin d'en savoir quoi que ce soit.
|
||
|
||
[#_the_whole_thing_6]
|
||
=== Le tout
|
||
|
||
Le voici, terminé. Si le vôtre ne correspond pas, la différence vaut la peine
|
||
d'être trouvée.
|
||
|
||
[source,lua]
|
||
----
|
||
dofile("Singe/Framework.singe")
|
||
|
||
local PLAYER_WIDTH = 40
|
||
local PLAYER_HEIGHT = 8
|
||
local PLAYER_SPEED = 4
|
||
local PLAYER_MARGIN = 4
|
||
local BLOCK_SIZE = 12
|
||
local BLOCK_SPEED = 1.5
|
||
local BLOCK_FASTER = 0.05
|
||
local SPAWN_FRAMES = 40
|
||
local START_LIVES = 3
|
||
local TEXT_COLUMNS = overlayGetWidth() // overlayGetFontWidth()
|
||
|
||
local screenWidth = overlayGetWidth()
|
||
local screenHeight = overlayGetHeight()
|
||
local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN
|
||
|
||
local playerX = (screenWidth - PLAYER_WIDTH) / 2
|
||
local goingLeft = false
|
||
local goingRight = false
|
||
local blocks = {}
|
||
local blockSpeed = BLOCK_SPEED
|
||
local spawnTimer = 0
|
||
local score = 0
|
||
local lives = START_LIVES
|
||
local state = "waiting"
|
||
|
||
|
||
function drawGame()
|
||
colorForeground(80, 255, 160)
|
||
overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1)
|
||
colorForeground(255, 90, 90)
|
||
for i, block in ipairs(blocks) do
|
||
overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1)
|
||
end
|
||
overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives)
|
||
end
|
||
|
||
|
||
function drawOverText()
|
||
printCentered(7, "GAME OVER")
|
||
printCentered(9, "PRESS SPACE TO PLAY AGAIN")
|
||
end
|
||
|
||
|
||
function drawWaitingText()
|
||
printCentered(6, "DODGE THE BLOCKS")
|
||
printCentered(8, "ARROW KEYS TO MOVE")
|
||
printCentered(10, "PRESS SPACE TO START")
|
||
end
|
||
|
||
|
||
function printCentered(row, text)
|
||
overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text)
|
||
end
|
||
|
||
|
||
function spawnBlock()
|
||
local block = {}
|
||
block.x = math.random(0, screenWidth - BLOCK_SIZE)
|
||
block.y = -BLOCK_SIZE
|
||
table.insert(blocks, block)
|
||
end
|
||
|
||
|
||
function startGame()
|
||
playerX = (screenWidth - PLAYER_WIDTH) / 2
|
||
blocks = {}
|
||
blockSpeed = BLOCK_SPEED
|
||
spawnTimer = 0
|
||
score = 0
|
||
lives = START_LIVES
|
||
state = "playing"
|
||
end
|
||
|
||
|
||
function updatePlaying()
|
||
if goingLeft then
|
||
playerX = playerX - PLAYER_SPEED
|
||
end
|
||
if goingRight then
|
||
playerX = playerX + PLAYER_SPEED
|
||
end
|
||
if playerX < 0 then
|
||
playerX = 0
|
||
elseif playerX > screenWidth - PLAYER_WIDTH then
|
||
playerX = screenWidth - PLAYER_WIDTH
|
||
end
|
||
|
||
spawnTimer = spawnTimer - 1
|
||
if spawnTimer <= 0 then
|
||
spawnBlock()
|
||
spawnTimer = SPAWN_FRAMES
|
||
end
|
||
|
||
for i = #blocks, 1, -1 do
|
||
local block = blocks[i]
|
||
block.y = block.y + blockSpeed
|
||
if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then
|
||
table.remove(blocks, i)
|
||
lives = lives - 1
|
||
if lives == 0 then
|
||
state = "over"
|
||
end
|
||
elseif block.y > screenHeight then
|
||
table.remove(blocks, i)
|
||
score = score + 1
|
||
blockSpeed = blockSpeed + BLOCK_FASTER
|
||
end
|
||
end
|
||
end
|
||
|
||
|
||
function onInputPressed(what)
|
||
if what == SWITCH_LEFT then
|
||
goingLeft = true
|
||
elseif what == SWITCH_RIGHT then
|
||
goingRight = true
|
||
elseif what == SWITCH_BUTTON1 and state ~= "playing" then
|
||
startGame()
|
||
end
|
||
end
|
||
|
||
|
||
function onInputReleased(what)
|
||
if what == SWITCH_LEFT then
|
||
goingLeft = false
|
||
elseif what == SWITCH_RIGHT then
|
||
goingRight = false
|
||
end
|
||
end
|
||
|
||
|
||
function onOverlayUpdate()
|
||
if state == "playing" then
|
||
updatePlaying()
|
||
end
|
||
|
||
overlayClear()
|
||
drawGame()
|
||
if state == "waiting" then
|
||
drawWaitingText()
|
||
elseif state == "over" then
|
||
drawOverText()
|
||
end
|
||
|
||
return OVERLAY_UPDATED
|
||
end
|
||
----
|
||
|
||
Environ cent vingt lignes de code, et vous les avez toutes écrites quelques-unes
|
||
à la fois.
|
||
|
||
[#_what_just_happened_4]
|
||
=== Ce qui vient de se passer
|
||
|
||
Quelques morceaux méritent un second regard.
|
||
|
||
[source,lua]
|
||
----
|
||
local blocks = {}
|
||
local score = 0
|
||
|
||
|
||
function startGame()
|
||
score = 0
|
||
end
|
||
----
|
||
|
||
Les données sont `local` et les fonctions ne le sont pas. Ce sont deux choix
|
||
délibérés, et opposés.
|
||
|
||
Les données sont `local` pour qu'elles appartiennent à ce fichier et que rien à
|
||
l'extérieur ne puisse y mettre la main et les changer. Les fonctions sont
|
||
globales pour qu'elles puissent s'appeler les unes les autres dans n'importe
|
||
quel ordre : `drawOverText` est défini avant `printCentered` et l'appelle quand
|
||
même, parce qu'un nom global est cherché au moment de l'appel, et qu'à ce
|
||
moment-là le fichier entier a été lu. Rendez ces fonctions `local` et le fichier
|
||
devra être trié pour que rien ne soit utilisé avant d'apparaître, et c'est une
|
||
règle sur laquelle vous trébucheriez pendant tout le reste du livre. Trois des
|
||
noms d'ici n'ont pas le choix du tout : `onOverlayUpdate` et les deux callbacks
|
||
des commandes doivent être globaux, parce que c'est ainsi que Singe les trouve.
|
||
|
||
Dans le fichier, l'ordre est alphabétique, avec les trois callbacks à la fin. Ce
|
||
n'est pas une règle de Lua. C'est pour que dans six mois vous puissiez trouver
|
||
`spawnBlock` sans rien lire.
|
||
|
||
[source,lua]
|
||
----
|
||
overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text)
|
||
----
|
||
|
||
`+#text+` est la longueur d'une chaîne, en caractères. Vous avez rencontré `+#+`
|
||
à la leçon six, pour compter les éléments d'une liste, et il fait le même
|
||
travail ici. `"GAME OVER"` fait neuf caractères, donc sur un écran de soixante
|
||
colonnes il commence à la colonne vingt-cinq, ce qui laisse vingt-cinq cellules
|
||
d'espace à sa gauche et vingt-six à sa droite.
|
||
`math.floor` jette la partie fractionnaire, parce que la colonne vingt-cinq et
|
||
demie n'existe pas.
|
||
|
||
`TEXT_COLUMNS` calcule combien de cellules de caractère tiennent sur la largeur
|
||
de l'écran. `overlayPrint` compte en cellules alors que tout le reste compte en
|
||
pixels, donc la largeur de l'overlay est divisée par la largeur d'une cellule,
|
||
et c'est `overlayGetFontWidth` qui vous la donne. Deux barres obliques plutôt
|
||
qu'une, parce qu'une fraction de colonne n'est pas un endroit où afficher.
|
||
Calculez-le de cette façon et le centrage reste juste quelle que soit la taille
|
||
de l'overlay, ce qui est le quatrième point de la section *Essayez*.
|
||
|
||
[source,lua]
|
||
----
|
||
colorForeground(255, 90, 90)
|
||
----
|
||
|
||
`colorForeground` règle une couleur qui reste réglée. Ce n'est pas un argument
|
||
passé à `overlayBox` ; c'est un interrupteur que vous basculez, et tout ce qui
|
||
est dessiné ensuite sort dans cette couleur jusqu'à ce que vous le basculiez de
|
||
nouveau. C'est pourquoi `drawGame` règle le vert, dessine le joueur, règle le
|
||
rouge, puis dessine tous les blocs sans plus parler de couleur.
|
||
|
||
Ça n'affecte pas `overlayPrint`. La police de la console est copiée sur
|
||
l'overlay telle quelle, dans ses propres couleurs, ce que l'entrée de
|
||
`overlayPrint` dans la Référence dit en toutes lettres. Du texte en couleur
|
||
demande une police chargée, et c'est la leçon dix-neuf.
|
||
|
||
[#_try_it_7]
|
||
=== Essayez
|
||
|
||
. *Rendez-le plus facile, puis injuste.* Changez `PLAYER_WIDTH` en `80` et
|
||
jouez une partie. Puis changez-le en `12`, la taille d'un bloc. Remarquez que
|
||
vous n'avez eu à toucher ni au dessin ni à la collision, ni pour l'un ni pour
|
||
l'autre.
|
||
. *Accélérez plus vite.* Changez `BLOCK_FASTER` de `0.05` à `0.5` et voyez
|
||
combien de points vous arrivez à marquer. Puis essayez `0`, et demandez-vous
|
||
si le jeu vaut encore la peine d'être joué.
|
||
. *Une vie en plus.* Rendez une vie au joueur tous les dix points. Tout ce dont
|
||
vous avez besoin est déjà dans `updatePlaying`, dans la branche qui ajoute au
|
||
score.
|
||
. *Jouez en plus grand.* Lancez-le avec `Singe -R --canvas=1280x720 dodge`. Le
|
||
jeu se met en page correctement, texte centré compris, parce qu'il a demandé
|
||
au moteur la taille de l'écran. `TEXT_COLUMNS` l'a demandée aussi.
|
||
. *Mettez-le en pause.* Ajoutez un quatrième état, `"paused"`.
|
||
`SWITCH_BUTTON2` doit faire passer de `"playing"` à `"paused"` et
|
||
inversement, et rien ne doit bouger pendant la pause. Bien fait, ça tient en
|
||
trois lignes et vous n'avez pas du tout à toucher à `updatePlaying`.
|
||
|
||
[#_break_it_on_purpose_7]
|
||
=== Cassez-le exprès
|
||
|
||
Allez dans `spawnBlock` et supprimez la ligne qui règle `block.y`, pour que la
|
||
fonction se lise ainsi :
|
||
|
||
[source,lua]
|
||
----
|
||
function spawnBlock()
|
||
local block = {}
|
||
block.x = math.random(0, screenWidth - BLOCK_SIZE)
|
||
table.insert(blocks, block)
|
||
end
|
||
----
|
||
|
||
Enregistrez, appuyez sur Espace, et Singe s'arrête avec ceci :
|
||
|
||
----
|
||
Error executing function 'onOverlayUpdate': dodge/dodge.singe:97: attempt to perform arithmetic on a nil value (field 'y')
|
||
stack traceback:
|
||
dodge/dodge.singe:97: in function 'updatePlaying'
|
||
dodge/dodge.singe:135: in function 'onOverlayUpdate'
|
||
----
|
||
|
||
La ligne 97, c'est `block.y = block.y + blockSpeed`. La plainte, c'est que
|
||
quelque chose dans ce calcul n'est pas un nombre, et la partie entre parenthèses
|
||
vous dit lequel : `field 'y'`, c'est-à-dire le `y` à l'intérieur d'une table.
|
||
Il n'a pas de valeur, parce que vous ne lui en avez jamais donné, et une chose
|
||
sans valeur, en Lua, c'est `nil`.
|
||
|
||
Remarquez où est l'erreur et où est la faute. L'erreur est dans
|
||
`updatePlaying`, à quarante lignes de `spawnBlock`, là où rien ne cloche du
|
||
tout. `updatePlaying` est seulement le premier morceau de code assez malchanceux
|
||
pour toucher aux dégâts. C'est le cas normal, pas l'exception, et remonter
|
||
cette piste est un savoir-faire.
|
||
|
||
Remettez la ligne. Puis tournez la page, parce que la leçon suivante ne parle
|
||
que de ça.
|
||
|
||
[#_what_you_learned_7]
|
||
=== Ce que vous avez appris
|
||
|
||
* Un jeu, c'est un monde qui change, un monde qu'on dessine, et une décision
|
||
sur ce qu'on fait des deux -- et ce sont trois morceaux de code séparés.
|
||
* Les gestionnaires de commandes doivent noter ce qui est enfoncé ; la mise à
|
||
jour doit décider ce que ça veut dire.
|
||
* Une liste d'enregistrements est la forme de presque tous les mondes de jeu.
|
||
* Retirez des choses d'une liste en la parcourant à rebours.
|
||
* `collideRects` répond à la question de savoir si deux rectangles se
|
||
chevauchent, et la version du moteur vaut mieux que la vôtre parce qu'elle
|
||
est juste, qu'elle se lit bien, et qu'elle a une famille que vous reverrez.
|
||
* Une seule variable `state` qui contient un mot vaut mieux que trois
|
||
indicateurs vrai-ou-faux, et continue de mieux valoir à mesure que le jeu
|
||
grandit.
|
||
* Lancez une partie depuis une seule fonction qui remet tout à zéro, pour que
|
||
la deuxième partie soit exactement comme la première.
|
||
* Les nombres que vous voudrez peut-être régler ont leur place dans des
|
||
constantes nommées en haut du fichier.
|
||
* Construisez un jeu par étapes qui tournent chacune. Un programme qui n'a
|
||
jamais marché est bien plus difficile à réparer qu'un programme qui marchait
|
||
il y a cinq minutes.
|
||
|
||
[#_next_time_7]
|
||
=== La prochaine fois
|
||
|
||
Vous avez maintenant écrit assez de code pour faire des erreurs intéressantes.
|
||
La leçon suivante est celle dont aucun livre pour débutants ne s'embarrasse : ce
|
||
que disent vraiment les messages d'erreur de Singe, comment découvrir ce que
|
||
contient réellement une variable au lieu de deviner, et comment couper un
|
||
problème en deux jusqu'à ce qu'il n'ait plus nulle part où se cacher.
|