singe/docs/fr/lessons/11-hitting-things.adoc

545 lines
22 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_11_hitting_things]
== Leçon 11 : Toucher des choses
image::learn/11-hitting-things.png[La leçon terminée, 480]
Votre jeu de la leçon dix marche. Les tirs font éclater les rochers, les
rochers coûtent des vies, et les explosions tombent aux bons endroits.
Regardez-le de près, pourtant, et vous verrez qu'il triche, dans les deux sens.
Un rocher qui frôle le bout d'une aile coûte une vie. Un tir qui traverse le
ciel vide à côté d'un rocher le fait éclater quand même.
Dans un jeu, deux choses se touchent quand les nombres le disent. Personne ne
regarde les images. Cette leçon met les nombres d'accord avec ce que voit le
joueur : elle remplace le test que vous avez écrit à la main par celui du
moteur, réduit les boîtes testées pour qu'elles collent aux choses qu'elles
contiennent, et vous donne un interrupteur qui vous montre ces boîtes. L'image
ci-dessus, c'est le jeu avec cet interrupteur allumé.
[#_where_you_are]
=== Où vous en êtes
Le jeu pose la question « ces deux-là se touchent-ils ? » à exactement deux
endroits, et les deux se servent de `overlapping`, la fonction que vous avez
tapée à la leçon neuf. L'un est dans `updateRocks`, là où un rocher rencontre
le vaisseau :
[source,lua]
----
if overlapping(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
L'autre est dans `updateShots`, là où un tir rencontre un rocher :
[source,lua]
----
if not gone and overlapping(shot.x, shot.y, shotWidth, shotHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
Tout ce que contient cette leçon change ces deux lignes et ajoute quelques
petites fonctions pour les nourrir. Rien d'autre ne bouge dans le jeu. Le
script terminé se trouve tout entier dans `learn/11-hitting-things.singe` si
vous vous perdez en route.
[#_read_it_once_more]
=== Relisez-la une fois de plus
La leçon neuf a déjà fait cette partie : `overlapping`, c'est la collision de
rectangles écrite à la main. Relisez-la une dernière fois avant de la jeter,
parce qu'elle contient toute l'idée :
[source,lua]
----
function overlapping(ax, ay, aw, ah, bx, by, bw, bh)
if ax + aw <= bx then
return false
end
if bx + bw <= ax then
return false
end
if ay + ah <= by then
return false
end
if by + bh <= ay then
return false
end
return true
end
----
Il est difficile de répondre directement à la question « ces deux-là se
touchent-ils ? », et facile d'y répondre à l'envers. Deux rectangles ne se
touchent *pas* quand l'un d'eux est entièrement à gauche de l'autre, ou
entièrement à droite, ou entièrement au-dessus, ou entièrement en dessous. Il
n'y a pas de cinquième façon de se rater. Si aucune des quatre n'est vraie, ils
se touchent.
Chaque `if` est l'une des quatre façons de se rater. `ax + aw` est le bord
droit du premier rectangle, donc `ax + aw <= bx` se lit « le premier finit
avant que le second commence », et c'est ce que « entièrement à gauche » veut
dire en nombres. La même forme encore trois fois, pour la droite, le dessus et
le dessous, avec `y` et les hauteurs à la place de `x` et des largeurs, et la
seule chose qui reste, c'est `true`.
Ce sont les huit nombres dont tout test de collision a besoin : un coin et une
taille pour chacune des deux choses. Gardez cette forme en tête, parce que vous
êtes sur le point de la supprimer.
[#_the_engine_already_has_it]
=== Le moteur l'a déjà
Vous avez rencontré `collideRects` à la leçon sept, où il attrapait les blocs
qui tombaient dans Dodge. La leçon neuf a écrit le même test à la main pour que
vous puissiez voir ce qu'il y a dedans. Vous l'avez vu. Supprimez
`overlapping`, et changez les deux lignes de *Où vous en êtes* pour qu'elles
appellent le moteur à la place :
[source,lua]
----
if collideRects(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
[source,lua]
----
if not gone and collideRects(shot.x, shot.y, shotWidth, shotHeight, rock.x, rock.y, rockWidth, rockHeight) then
----
Les mêmes huit nombres, dans le même ordre : le coin et la taille du premier
rectangle, puis ceux du second. Lancez-le, et le jeu se joue exactement comme
avant, et c'est bien le but. La seule différence est tout au bord. Votre `<=`
disait que deux rectangles qui se rejoignent le long d'un bord ne se touchent
pas. `collideRects` dit qu'ils se touchent, et un rectangle sans largeur ou sans
hauteur ne touche rien du tout. C'est dans ces cas délicats que les versions
écrites à la main se trompent sans bruit, et c'est une des raisons de se servir
de celle du moteur.
`collideRects` fait partie d'une famille de tests de collision, et cette
famille est une partie de ce que ce livre appelle le *kit de jeu* : une poignée
de fonctions simples pour les choses que presque tous les jeux écrivaient
autrefois eux-mêmes. Tester si deux choses se touchent en est une. Garder un
nombre pour la prochaine fois en est une autre, et c'est la leçon douze.
Attendre un moment, faire glisser un nombre en douceur d'une valeur à une
autre, et montrer combien de temps chaque image a pris, voilà le reste. La
Référence donne à chaque partie sa propre section : Collisions, Sauvegarde,
Minuteur, Tween et Statistiques.
Les tests de collision sont les plus simples du lot : de simples questions,
sans handle à garder et sans rien à préparer ni à libérer. Et aucun d'eux ne
déplace quoi que ce soit. `collideRects` vous dit que le vaisseau et le rocher
sont au même endroit ; c'est à vous de décider quoi en faire, et tout ce qui est
intéressant dans un jeu se passe dans cette décision.
[#_the_picture_is_not_the_hit_box]
=== L'image n'est pas la boîte de collision
La leçon dix a promis d'expliquer pourquoi le rectangle autour d'un rocher rond
n'a pas la bonne forme. Le rectangle autour du vaisseau n'a pas la bonne forme
non plus, et pour la même raison.
Dessinez sur papier le rectangle de 32 sur 24 du vaisseau et coloriez le
vaisseau. C'est un triangle avec deux ailerons, donc il reste du ciel vide dans
les deux coins du haut et entre les ailerons. Le rocher est rond, donc il reste
du ciel vide dans les quatre coins de son carré. Un rocher qui frôle le coin
supérieur du rectangle du vaisseau n'a pas touché le vaisseau. Il est passé à
côté de l'aile, et un joueur qui perd une vie pour ça dira que le jeu a
triché -- et il aura raison.
Donc la boîte que vous testez n'est pas l'image que vous dessinez. Elle est plus
petite, et elle se trouve à l'intérieur de l'image. Ajoutez trois nombres aux
constantes en haut de votre fichier :
[source,lua]
----
local SHIP_PAD_X = 6
local SHIP_PAD_Y = 5
local ROCK_PAD = 4
----
La marge, c'est la distance entre chaque bord et l'endroit où commence la vraie
chose. Six pixels de moins de chaque côté du vaisseau, et cinq en haut et en
bas, laissent une boîte de 20 sur 14 au milieu d'une image de 32 sur 24, ce qui
correspond à peu près à la coque. Quatre de moins de chaque côté du rocher
laissent 16 sur 16, et c'est presque entièrement du rocher.
Les deux boîtes sont un peu plus petites que les choses qu'elles représentent,
et c'est voulu. Une boîte un peu trop petite donne au joueur des passages de
justesse qui ressemblent à de l'adresse. Une boîte un peu trop grande lui donne
des coups qui ressemblent à de la triche. Les joueurs pardonnent le premier cas,
jamais le second.
Plutôt que d'écrire ce calcul à chaque test, écrivez-le une fois pour chaque
sorte de chose :
[source,lua]
----
function rockBox(rock)
return rock.x + ROCK_PAD, rock.y + ROCK_PAD, rockWidth - ROCK_PAD * 2, rockHeight - ROCK_PAD * 2
end
function shipBox()
return shipX + SHIP_PAD_X, shipY + SHIP_PAD_Y, shipWidth - SHIP_PAD_X * 2, shipHeight - SHIP_PAD_Y * 2
end
----
Les tailles viennent de `rockWidth`, de `shipWidth` et de leurs amis, les
mesures que la leçon neuf a prises sur les images, donc il n'y a toujours pas
un seul nombre de pixels brut dans le code du jeu. Redessinez le vaisseau plus
grand et la boîte grandit avec lui.
Chacune de ces fonctions renvoie quatre nombres à la fois, séparés par des
virgules. Une fonction peut renvoyer autant de valeurs qu'elle veut, et la
façon de les attraper est une liste de noms à gauche du `=` :
[source,lua]
----
function shipHitsRock(rock)
local sx, sy, sw, sh = shipBox()
local rx, ry, rw, rh = rockBox(rock)
return collideRects(sx, sy, sw, sh, rx, ry, rw, rh)
end
----
Maintenant, le test de `updateRocks` se lit comme la question qu'il pose :
[source,lua]
----
if shipHitsRock(rock) then
----
Le calcul de la marge se trouve à exactement deux endroits, et les nombres qu'il
utilise sont en haut du fichier : vous pouvez donc régler l'équité du jeu en
changeant un nombre plutôt qu'en fouillant dans vos tests.
Rangez les nouvelles fonctions à leur place dans l'ordre alphabétique, comme l'a
demandé la leçon neuf : `rockBox` après `onSoundCompleted`, puis `shipBox` et
`shipHitsRock`.
[#_firing_back]
=== Riposter
Voilà pour le côté du vaisseau. L'autre collision du jeu, c'est celle avec
laquelle vous ripostez : chaque tir contre chaque rocher, dans `updateShots`.
Elle a le même problème que le vaisseau, en plus petit. Un tir est un rectangle
de 4 sur 10 testé contre le carré entier de 24 sur 24 du rocher, donc un tir
qui ne fait que traverser le coin vide à côté d'un rocher le fait éclater quand
même.
Vous pourriez donner au tir sa propre boîte avec une marge. Regardez d'abord les
nombres, pourtant. Retirez une marge à un tir de quatre pixels de large et il ne
reste presque rien, et tester un rectangle entier pour savoir si un filet de
lumière a touché quelque chose, c'est plus de machinerie que la question n'en
mérite.
[#_a_shot_is_almost_a_point]
=== Un tir est presque un point
Testez plutôt la pointe. `collidePointRect` demande si un point tombe à
l'intérieur d'un rectangle -- deux nombres pour le point, puis le coin et la
taille du rectangle :
[source,lua]
----
function shotHitsRock(shot, rock)
local rx, ry, rw, rh = rockBox(rock)
return collidePointRect(shot.x + shotWidth / 2, shot.y, rx, ry, rw, rh)
end
----
`shot.y` est le haut du tir et `shot.x + shotWidth / 2` est le milieu de sa
largeur, donc le point testé est le milieu du nez du tir. C'est là qu'un joueur
pense que se trouve un tir. Le côté rocher de la question se sert du même
`rockBox` que le vaisseau, donc un rocher a la même taille, quoi qui le touche.
Elle va après `shipHitsRock`, et le test de `updateShots` devient :
[source,lua]
----
if not gone and shotHitsRock(shot, rock) then
----
[#_deciding_what_happened]
=== Décider de ce qui s'est passé
Aucun des tests de collision ne fait quoi que ce soit. Ils répondent, et c'est
votre code qui décide. Voici les deux endroits qui décident, avec leurs
nouvelles questions dedans. D'abord `updateRocks` :
[source,lua]
----
if shipHitsRock(rock) then
local channel = soundPlay(boomClip)
newRock(rock)
lives = lives - 1
if lives <= 0 then
dying = true
deathChannel = channel
if channel < 0 then
endGame()
end
end
end
----
Et la boucle à l'intérieur de `updateShots` :
[source,lua]
----
for _, rock in ipairs(rocks) do
if not gone and shotHitsRock(shot, rock) then
newRock(rock)
score = score + HIT_SCORE
gone = true
soundPlay(boomClip, 0, 45)
end
end
----
Tout ce qui se trouve sous les questions est exactement ce que la leçon dix y a
laissé. Un rocher qui touche le vaisseau fait boum, retourne en haut et coûte
une vie. Un rocher abattu fait boum, retourne en haut et rapporte des points.
Le tir disparaît aussi, et le `not gone` empêche un seul tir de faire éclater
deux rochers. Changez quoi que ce soit là-dedans et vous changez le jeu, sans
toucher à un seul test de collision, et c'est la raison pour laquelle les
questions et les réponses sont gardées à part.
Lancez-le. Passez tout près d'un rocher et laissez-le effleurer un aileron :
vous gardez la vie. Tirez juste à côté d'un rocher et le tir passe sans le
toucher. Tirez en plein milieu d'un rocher et il éclate, comme toujours.
[#_seeing_the_boxes]
=== Voir les boîtes
Les boîtes de collision sont invisibles, ce qui rend invisible aussi toute
erreur qui s'y cache. Dessinez-les pendant que vous travaillez. Une constante de
plus en haut :
[source,lua]
----
local SHOW_BOXES = false
----
Deux fonctions de plus, qui vont tout en haut de l'ordre alphabétique :
[source,lua]
----
function drawBox(x, y, width, height)
overlayBox(x, y, x + width - 1, y + height - 1)
end
function drawBoxes()
colorForeground(0, 255, 0)
drawBox(shipBox())
for _, rock in ipairs(rocks) do
drawBox(rockBox(rock))
end
end
----
Et dans `onOverlayUpdate`, après le dessin du vaisseau et avant que le score
vienne par-dessus :
[source,lua]
----
spriteDraw(shipSprite, shipX, shipY)
if SHOW_BOXES then
drawBoxes()
end
drawHud()
----
Passez `SHOW_BOXES` à `true` et des contours verts apparaissent par-dessus les
graphismes, comme sur l'image en haut de cette leçon. Remettez-le comme avant
quand vous avez fini. Un interrupteur comme celui-ci, un mot en haut d'un
fichier qui allume une image de ce que croit le programme, vaut mieux que des
heures à fixer l'écran.
[#_what_just_happened_7]
=== Ce qui vient de se passer
[source,lua]
----
function rockBox(rock)
return rock.x + ROCK_PAD, rock.y + ROCK_PAD, rockWidth - ROCK_PAD * 2, rockHeight - ROCK_PAD * 2
end
----
Quatre valeurs pour un seul `return`, séparées par des virgules. Lua renvoie
volontiers autant de valeurs que vous voulez, et c'est l'appelant qui décide
combien il en garde. Quand un appel comme celui-ci est la *dernière* chose entre
les parenthèses d'un autre appel, les quatre sont transmises, et c'est pourquoi
`drawBox(rockBox(rock))` marche et donne à `drawBox` ses quatre arguments.
Partout ailleurs dans une liste d'arguments, seule la première valeur survit.
Cette règle mord exactement une fois, et elle mord à la fin de cette leçon.
[source,lua]
----
return collideRects(sx, sy, sw, sh, rx, ry, rw, rh)
----
`collideRects` veut huit nombres : un coin et une taille, un coin et une
taille. Il renvoie `true` ou `false` et ne change rien. Comme la réponse ressort
directement de la fonction avec `return`, `shipHitsRock(rock)` se lit comme une
question partout où on s'en sert.
[source,lua]
----
return collidePointRect(shot.x + shotWidth / 2, shot.y, rx, ry, rw, rh)
----
`collidePointRect` en veut six : un point, puis un rectangle. Le bord compte
comme l'intérieur, donc un tir pile sur la limite touche. Servez-vous-en chaque
fois qu'un côté de la question est assez petit pour que sa taille n'ait pas
d'importance.
[source,lua]
----
colorForeground(0, 255, 0)
overlayBox(x, y, x + width - 1, y + height - 1)
----
Ces deux appels viennent de la leçon sept. `overlayBox` prend deux *coins*, pas
un coin et une taille -- le seul endroit de cette leçon où les nombres changent
de forme -- et le `- 1` est là parce que les deux coins sont inclus.
`colorForeground` est un interrupteur qui reste basculé, donc tout ce que
dessine `drawBoxes` sort en vert. Les sprites et `overlayPrint` n'en tiennent
pas compte, donc le reste du jeu garde le même aspect.
[#_the_rest_of_the_family]
=== Le reste de la famille
`collideRects` et `collidePointRect` suffiront à la plupart des jeux 2D. Les
autres sont là pour les formes que les rectangles décrivent mal, et chacun est
le même genre de question simple :
* `collideCircles` et `collidePointCircle`, pour tout ce qui est rond. Un
cercle, c'est un centre et un rayon.
* `collideRectCircle`, pour une chose ronde contre une chose carrée.
* `collidePointPolygon`, pour une forme qui a ses propres coins -- un dragon, un
continent, un bouton à la forme bizarre. Vous lui donnez une simple liste de
nombres `x`, `y`, `x`, `y`.
* `collideSegments`, pour deux segments qui se croisent.
Soyons honnêtes : votre rocher est rond, et les tests de cercle lui vont mieux
que n'importe quelle boîte. La marge, c'est la réponse bon marché, et elle
suffit à la plupart des jeux, et c'est pourquoi elle vient en premier. La
réponse exacte fait partie des choses à essayer plus bas.
`collideSegments` compte plus qu'on ne le croirait. Votre tir avance de six
pixels par image et les rochers ont seize pixels de boîte de collision, donc un
tir ne peut pas en dépasser un sans être testé à l'intérieur. Rendez le tir
assez rapide -- quarante pixels par image -- et il saute d'en dessous d'un
rocher à au-dessus, sans se trouver une seule fois au même endroit que lui, et
il ne touche jamais rien. Tracer la ligne entre l'endroit où était le tir et
celui où il est maintenant, et demander à `collideSegments` si cette ligne
croise un bord, voilà le remède. Les choses lentes n'en ont pas besoin.
Il y a un seul callback de collision dans Singe, `onCollision`, et il n'est pas
fait pour ça. Il appartient au moteur physique 3D : des corps qui ont une masse,
qui tombent et rebondissent les uns sur les autres en trois dimensions, et qui
vous disent où ils se sont touchés et avec quelle force. Vous le rencontrerez à
la leçon vingt-quatre. Rien sur l'overlay ne se signale tout seul, et rien n'en
a besoin. Vos rochers sont dans une liste, vous savez combien il y en a, et
demander ne coûte pas cher.
Et quand vous voulez savoir non seulement *si* deux choses se sont rencontrées,
mais aussi où celle qui bouge devrait finir -- en glissant le long d'un mur au
lieu de s'arrêter net dedans -- les appels collide ne répondent pas à cette
question, et c'est voulu. Une bibliothèque appelée `bump` est fournie avec Singe
exactement pour ce travail, et la section Bibliothèques incluses de la Référence
explique comment y accéder.
[#_try_it_11]
=== Essayez
. *Retirez la marge.* Mettez `ROCK_PAD` à `0` et jouez une minute. Puis
mettez-le à `8` et rejouez. Avec l'un des deux, on a l'impression que le jeu
vous ment.
. *Regardez ce que vous testez.* Mettez `SHOW_BOXES` à `true` et observez les
contours verts pendant qu'un rocher passe près du vaisseau. Maintenant,
mettez `SHIP_PAD_X` à `16`, jouez, et remarquez que plus rien ne peut vous
toucher. Trouvez pourquoi à partir du calcul de `shipBox`.
. *Rendez le tir trop rapide.* Mettez `SHOT_SPEED` à `40`. Comptez combien de
rochers vous touchez sur dix. Trouvez avec les nombres pourquoi ça arrive
avant de le remettre comme avant.
. *Servez-vous du tir entier.* Réécrivez `shotHitsRock` pour qu'il utilise
`collideRects` avec le rectangle complet du tir au lieu de `collidePointRect`
avec son nez. Jouez avec les deux et décidez lequel vous préférez ; il n'y a
pas de bonne réponse, et c'est bon à savoir.
. *Rendez le rocher rond.* Donnez au rocher un cercle au lieu d'une boîte : son
centre est à `rock.x + rockWidth / 2` vers la droite et à
`rock.y + rockHeight / 2` vers le bas, et un rayon de `10` convient à peu
près. Servez-vous de `collideRectCircle` pour le vaisseau et de
`collidePointCircle` pour le tir, et dessinez le cercle dans `drawBoxes` avec
`overlayCircle`, qui prend un centre et un rayon.
[#_break_it_on_purpose_10]
=== Cassez-le exprès
Les deux boîtes font déjà quatre valeurs chacune, alors on est tenté de les
passer directement et de se passer des huit noms locaux :
[source,lua]
----
function shipHitsRock(rock)
return collideRects(shipBox(), rockBox(rock))
end
----
Ça a l'air juste, et c'est faux. Lancez-le et le jeu s'arrête avant d'avoir
dessiné quoi que ce soit :
----
186:collideRects: Expected 8 argument(s), got 5.
----
La ligne est celle que vous venez de taper, donc la vôtre n'est peut-être
pas la 186. Cinq, par contre. `rockBox(rock)` est la dernière chose entre les
parenthèses, donc ses quatre valeurs sont toutes entrées. `shipBox()` ne l'est
pas, donc il a été réduit à une seule. Un plus quatre font cinq, et
`collideRects` ne va pas deviner les trois autres.
Cette erreur vient du moteur et non de Lua, donc elle a la même forme que
celles des leçons neuf et dix : le nombre au début est la ligne, puis vient le
nom de la fonction qui a refusé, puis la plainte. Remettez les huit noms locaux.
[#_what_you_learned_11]
=== Ce que vous avez appris
* La collision, c'est de l'arithmétique sur des rectangles, et vous pouvez
l'écrire vous-même.
* Deux rectangles se ratent d'exactement quatre façons ; si aucune n'est vraie,
ils se touchent.
* `collideRects` prend un coin et une taille pour chacun de deux rectangles et
répond `true` ou `false`. Pour lui, des bords qui ne font que se toucher se
touchent.
* Les tests de collision font partie du kit de jeu : de simples fonctions, sans
rien à préparer. Sauvegarde, Minuteur, Tween et Statistiques en sont le reste.
* `collidePointRect` demande si un point est à l'intérieur d'un rectangle, ce
qui est la bonne question pour tout ce qui est petit.
* La boîte que vous testez est plus petite que l'image que vous dessinez, sinon
le jeu semble injuste.
* Une fonction peut renvoyer plusieurs valeurs, et seul le dernier appel d'une
liste d'arguments les transmet toutes.
* Les tests de collision ne font que répondre. Ce qui se passe ensuite, c'est
votre code, et garder les deux à part vous permet de changer l'un sans
l'autre.
* Dessiner vos boîtes de collision à l'écran transforme un bogue invisible en
bogue visible.
* `onCollision` est pour les corps de la physique 3D, pas pour les sprites de
l'overlay.
[#_next_time_11]
=== La prochaine fois
Le jeu compte votre score et vous prend vos vies, et dès que vous quittez, le
meilleur score jamais obtenu disparaît. Il démarre au milieu de l'action à
l'instant où il se charge. Et il garde la trace de ce qu'il fait avec deux
variables vrai-ou-faux, `over` et `dying`, ce qui est exactement le piège dont
la leçon sept vous a prévenu. La leçon douze lui donne un écran titre, une
seule variable à la place de ces deux-là, et un meilleur score qui est toujours
là le lendemain.