singe/docs/fr/lessons/18-qte.adoc

627 lines
22 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_18_quick_time_events]
== Leçon 18 : Les quick-time events
image::learn/18-qte.png[La leçon terminée, 480]
Une invite apparaît. Vous avez une demi-seconde. Appuyez sur la bonne chose et
l'histoire continue ; appuyez sur la mauvaise, ou sur rien du tout, et elle part
ailleurs.
C'est toute l'idée, et un nombre surprenant de jeux en vidéo ne sont faits de
presque rien d'autre. Vous avez déjà toutes les pièces : la leçon quatorze vous
a donné les images, la leçon six une liste d'enregistrements, la leçon trois les
entrées, et la leçon seize les embranchements. Cette leçon les assemble et
ajoute une chose vraiment nouvelle -- une fenêtre de temps, et ce qui se passe
quand elle se ferme.
[#_the_clock_is_the_film]
=== L'horloge, c'est le film
Tout ici se mesure en *images*, pas en secondes, et ça vaut la peine de s'y
arrêter, parce que c'est la décision qui, à elle seule, fait que tout ça marche.
La leçon quatorze disait qu'une image est l'adresse d'un moment du film.
L'invite doit apparaître quand l'acteur commence à frapper, et la fenêtre doit
se fermer quand le poing arrive. Ce ne sont pas des instants, ce sont des
endroits dans les images, et vous les avez trouvés en les parcourant pas à pas.
Si vous écrivez la fenêtre en secondes, vous devez lancer un chronomètre quand
l'invite apparaît, et alors les deux horloges -- la vôtre et celle du film --
peuvent se décaler. Si la vidéo saccade sur une machine lente, votre chronomètre
continue de tourner et le joueur est puni pour son matériel.
Les images ne peuvent pas se décaler, parce qu'il n'y a qu'une seule horloge.
`discGetFrame()` est l'heure, toujours. Une fenêtre, c'est deux numéros d'image,
et « combien de temps reste-t-il », c'est une soustraction.
Lancez avec la même vidéo qu'avant :
----
Singe -R -v Singe/menuBackground.mkv moves
----
Toujours la grille violette et le coucher de soleil. Imaginez un combat.
[#_a_move_is_a_record]
=== Un mouvement est un enregistrement
Voici la table sur laquelle repose toute la leçon :
[source,lua]
----
local moves = {
{ first = 60, last = 105, switch = SWITCH_LEFT, name = "LEFT" },
{ first = 140, last = 185, switch = SWITCH_UP, name = "UP" },
{ first = 220, last = 265, switch = SWITCH_RIGHT, name = "RIGHT" },
{ first = 300, last = 345, switch = SWITCH_DOWN, name = "DOWN" },
}
----
Quatre mouvements. Chacun dit quand la fenêtre s'ouvre, quand elle se ferme,
quel contact y répond, et quoi mettre à l'écran. Quarante-cinq images, c'est
environ une seconde et demie à la fréquence d'images de cette vidéo, ce qui est
généreux ; les vrais jeux vous en donnent souvent moins de la moitié.
Tout ce qui fait qu'un mouvement est un mouvement se trouve dans cette table. Il
n'y a de code nulle part plus bas qui sache qu'il y a quatre mouvements, ou que
le deuxième est vers le haut, ou que la première fenêtre s'ouvre à l'image
soixante. Ajouter un cinquième mouvement, c'est ajouter une ligne. Changer le
rythme de tous les mouvements, c'est modifier des nombres. Personne n'a besoin
de lire une fonction pour changer le combat.
Ce n'est pas une astuce propre à cette leçon. C'est la différence entre un jeu
que vous pouvez régler et un jeu que vous ne pouvez pas régler, et ça ne coûte
rien de le faire dès le début. Quand vous vous surprenez sur le point d'écrire
`if frame == 60 then` pour la quatrième fois, arrêtez-vous et faites plutôt une
table.
[#_what_happens_when]
=== Ce qui se passe, et quand
Trois choses peuvent arriver à un mouvement, et ce sont trois issues
différentes, pas deux :
* Le joueur appuie sur le bon contact pendant la fenêtre. Comptez les points et
continuez.
* Le joueur appuie sur un *mauvais* contact pendant la fenêtre. Il a essayé et
il a échoué.
* La fenêtre se ferme sans aucune réponse. Il est resté figé.
Le code paresseux traite les deux derniers cas de la même façon. Les jeux ne le
font presque jamais, parce qu'à jouer, ils font un effet complètement
différent : se tromper est une erreur, rester figé en est une autre, et le film
part généralement ailleurs pour chacune. Ici, la mauvaise réponse envoie le
disque à l'image `362` et l'absence de réponse l'envoie à l'image `388`, ce qui,
avec ces images-là, veut dire deux quantités de coucher de soleil un peu
différentes. Avec de vraies images, ce seraient deux plans différents de vous en
train de perdre.
Chaque enregistrement gagne donc un cinquième champ pendant la partie --
`answer` -- qui vaut `nil` jusqu'à ce qu'il arrive quelque chose au mouvement,
puis l'une de trois chaînes :
[source,lua]
----
move.answer = "right"
move.answer = "wrong"
move.answer = "late"
----
`nil` est la valeur que Lua vous donne pour un champ que personne n'a rempli.
Vous l'avez rencontré à la leçon trois. Ici, il mérite sa place : « pas encore de
réponse » est vraiment un état différent de chacune des trois réponses, et `nil`
le dit sans que vous ayez à inventer un mot pour ça.
[#_finding_the_move_that_is_being_asked]
=== Trouver le mouvement demandé
Tout le reste a besoin de savoir une chose : quel mouvement, s'il y en a un, est
ouvert en ce moment ?
[source,lua]
----
local function moveAt(frame)
for _, move in ipairs(moves) do
if move.answer == nil and frame >= move.first and frame <= move.last then
return move
end
end
return nil
end
----
Parcourez la liste, et répondez avec le premier mouvement dont la fenêtre couvre
cette image et qui n'a pas encore reçu de réponse. Si rien ne correspond,
répondez `nil`.
Renvoyer `nil` exprès, ça mérite d'être remarqué. Une fonction qui parfois n'a
pas de réponse devrait le dire, et « aucun mouvement n'est ouvert » est une très
bonne réponse. Le code qui l'appelle écrit alors `if move ~= nil then`, et le
code qui dessine l'invite ne s'exécute jamais quand il n'y a rien à demander.
Le test `move.answer == nil` est ce qui empêche qu'un mouvement soit demandé
deux fois. Dès que le joueur a la bonne réponse, l'invite disparaît même si la
fenêtre est encore ouverte -- et c'est exactement ce qu'elle doit faire,
puisqu'il a répondu.
[#_reading_the_answer]
=== Lire la réponse
[source,lua]
----
function onInputPressed(what)
if not isDirection(what) then
return
end
local move = moveAt(discGetFrame())
if move == nil then
return
end
if what == move.switch then
move.answer = "right"
score = score + RIGHT_SCORE
else
move.answer = "wrong"
endRun("WRONG WAY", WRONG_FRAME)
end
end
----
Lisez-le de haut en bas. Si le contact n'est pas l'une des quatre directions, ça
ne nous regarde pas -- on s'en va. Si aucun mouvement n'est ouvert, un appui sur
une direction ne veut rien dire -- on s'en va. Sinon, il y a exactement un
mouvement auquel comparer, et une seule comparaison à faire.
Ces deux lignes `return` du début sont des garde-fous : elles écartent les cas
qui ne vous intéressent pas, pour que le code en dessous ne s'exécute jamais que
dans la seule situation pour laquelle il a été écrit. L'écrire dans l'autre
sens, avec le vrai travail imbriqué sur trois niveaux à l'intérieur de trois
blocs `if`, dit la même chose et se lit beaucoup plus difficilement.
`isDirection` est une toute petite fonction qui ne fait rien d'autre que garder
cette première ligne courte :
[source,lua]
----
local function isDirection(what)
return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT
end
----
Remarquez qu'elle renvoie la comparaison elle-même au lieu de dire
`if ... then return true else return false end`. Une comparaison vaut déjà
`true` ou `false`. Tester si quelque chose est vrai pour pouvoir dire que c'est
vrai, c'est une habitude qu'il vaut mieux perdre tôt.
[#_the_window_closing]
=== La fenêtre qui se ferme
Personne n'appuie sur rien, et les images continuent de défiler. Quelque chose
doit le remarquer. Ce quelque chose, c'est `onOverlayUpdate`, parce qu'il
s'exécute à chaque image, que le joueur fasse quelque chose ou non :
[source,lua]
----
if not runOver then
for _, move in ipairs(moves) do
if move.answer == nil and frame > move.last then
move.answer = "late"
endRun("TOO SLOW", LATE_FRAME)
break
end
end
end
----
Un mouvement sans réponse dont la dernière image est passée a été manqué.
`break` quitte la boucle immédiatement, parce qu'une fois la partie finie, ça ne
sert à rien de regarder le reste.
`endRun` est l'embranchement, et c'est le même embranchement que celui que vous
avez écrit à la leçon seize :
[source,lua]
----
local function endRun(reason, frame)
verdict = reason
runOver = true
discSkipToFrame(frame)
end
----
Trois lignes. Retenir quoi dire, retenir que la partie est finie, et envoyer le
film ailleurs. Un quick-time event manqué n'est pas un mécanisme spécial. C'est
un choix que le joueur a fait en n'en faisant pas, et le film bifurque dessus
exactement comme il bifurque sur une porte qu'il a choisie.
[#_a_gauge_that_drains]
=== Une jauge qui se vide
Le joueur ne peut pas voir les numéros d'image. Il a besoin de voir le temps
s'écouler, et de le voir sans quitter l'action des yeux, ce qui veut dire une
forme qui change plutôt qu'un nombre qui compte.
Deux nombres vous donnent tout :
[source,lua]
----
local left = move.last - frame
local span = move.last - move.first
----
`span` est la longueur de la fenêtre. `left` est ce qu'il en reste. La barre
fait `left` sur `span` de la largeur totale :
[source,lua]
----
fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H)
----
`//` est une division qui jette la partie fractionnaire, ce que vous voulez ici,
parce qu'une barre de cent trente-trois pixels et un tiers de large, ça
n'existe pas. Multipliez avant de diviser -- `GAUGE_W * left // span` et non
`GAUGE_W * (left // span)` -- sinon la fraction est jetée alors qu'elle est
encore la seule information que vous avez, et la barre saute de pleine à vide
sans rien entre les deux.
Il n'y a pas de rectangle plein dans l'overlay. `overlayBox` dessine un contour,
ce qui est ce que vous voulez pour le cadre autour de la jauge, et le
remplissage est à votre charge :
[source,lua]
----
local function fillBar(x, y, width, height)
if width < 1 then
return
end
for row = 0, height - 1 do
overlayLine(x, y + row, x + width - 1, y + row)
end
end
----
Dix-huit traits horizontaux empilés les uns sur les autres. Le garde-fou du
début compte : à la toute dernière image de la fenêtre, `left` vaut zéro, la
largeur vaut zéro, et sans lui, `x + width - 1` est un pixel à *gauche* de `x`
et vous dessinez un petit trait à l'envers au lieu de rien.
La touche finale, c'est un changement de couleur quand ça devient désespéré :
[source,lua]
----
if left * 3 < span then
colorForeground(255, 60, 60, 255)
else
colorForeground(255, 200, 0, 255)
end
----
La barre devient rouge dans son dernier tiers. Écrit comme ça -- en multipliant
le temps restant plutôt qu'en divisant la durée -- tout reste en nombres entiers
et ça marche pour une fenêtre de n'importe quelle longueur.
[#_the_whole_thing_10]
=== Le tout
[source,lua]
----
-- Apprendre à programmer avec Singe -- Leçon 18 : Les quick-time events
dofile("Singe/Framework.singe")
overlaySetResolution(discGetWidth(), discGetHeight())
local FIRST_FRAME = 20
local WRONG_FRAME = 362
local LATE_FRAME = 388
local LAST_FRAME = 415
local PROMPT_COL = 54
local PROMPT_ROW = 22
local GAUGE_X = 210
local GAUGE_Y = 330
local GAUGE_W = 300
local GAUGE_H = 18
local RIGHT_SCORE = 100
local moves = {
{ first = 60, last = 105, switch = SWITCH_LEFT, name = "LEFT" },
{ first = 140, last = 185, switch = SWITCH_UP, name = "UP" },
{ first = 220, last = 265, switch = SWITCH_RIGHT, name = "RIGHT" },
{ first = 300, last = 345, switch = SWITCH_DOWN, name = "DOWN" },
}
local score = 0
local runOver = false
local verdict = ""
local function fillBar(x, y, width, height)
if width < 1 then
return
end
for row = 0, height - 1 do
overlayLine(x, y + row, x + width - 1, y + row)
end
end
local function isDirection(what)
return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT
end
local function moveAt(frame)
for _, move in ipairs(moves) do
if move.answer == nil and frame >= move.first and frame <= move.last then
return move
end
end
return nil
end
local function startRun()
score = 0
runOver = false
verdict = ""
for _, move in ipairs(moves) do
move.answer = nil
end
discSkipToFrame(FIRST_FRAME)
end
local function endRun(reason, frame)
verdict = reason
runOver = true
discSkipToFrame(frame)
end
function onInputPressed(what)
if runOver then
if what == SWITCH_START1 then
startRun()
end
return
end
if not isDirection(what) then
return
end
local move = moveAt(discGetFrame())
if move == nil then
return
end
if what == move.switch then
move.answer = "right"
score = score + RIGHT_SCORE
else
move.answer = "wrong"
endRun("WRONG WAY", WRONG_FRAME)
end
end
function onOverlayUpdate()
local frame = discGetFrame()
if not runOver then
for _, move in ipairs(moves) do
if move.answer == nil and frame > move.last then
move.answer = "late"
endRun("TOO SLOW", LATE_FRAME)
break
end
end
end
if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then
if not runOver then
verdict = "CLEAR"
runOver = true
end
discSearch(LAST_FRAME)
end
overlayClear()
local move = nil
if not runOver then
move = moveAt(frame)
end
if move ~= nil then
local left = move.last - frame
local span = move.last - move.first
overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name)
colorForeground(255, 255, 255, 255)
overlayBox(GAUGE_X - 2, GAUGE_Y - 2, GAUGE_X + GAUGE_W + 1, GAUGE_Y + GAUGE_H + 1)
if left * 3 < span then
colorForeground(255, 60, 60, 255)
else
colorForeground(255, 200, 0, 255)
end
fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H)
end
overlayPrint(2, 1, "SCORE " .. score .. " FRAME " .. frame)
if runOver then
overlayPrint(2, 3, verdict)
overlayPrint(2, 4, "PRESS 1 TO RUN IT AGAIN")
end
return OVERLAY_UPDATED
end
startRun()
----
Les touches fléchées répondent aux invites. Réussissez les quatre et le film va
jusqu'au bout et s'y arrête sur `CLEAR`. Ratez-en une, ou restez les bras
croisés, et il saute à une autre image avec un autre mot à l'écran. `1` relance
le tout.
[#_what_just_happened_14]
=== Ce qui vient de se passer
Vous en avez déjà lu presque tout. Quatre morceaux méritent qu'on y revienne.
[source,lua]
----
if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then
----
La fin du film doit être attrapée, sinon il dépasse la fin et il n'y a plus rien
à voir. `discSearch` va à une image et s'y *met en pause*, ce qui est exactement
ce que veut un plan final. La moitié `discGetState()` empêche que ça se
reproduise à chacune des images suivantes : une fois le disque en pause, il
n'est plus `DISC_PLAYING`, donc la condition est fausse et le saut n'est pas
répété soixante fois par seconde. `discGetState` et les constantes `DISC_*` sont
dans la Référence, à l'entrée `discGetState`.
[source,lua]
----
local move = nil
if not runOver then
move = moveAt(frame)
end
----
`onOverlayUpdate` redemande le mouvement ouvert, au lieu que `onInputPressed` le
lui dise. C'est voulu. Il y a exactement une fonction qui sait quel mouvement
est ouvert, et tout le monde lui pose la question. Si l'invite venait d'une
variable que le code des entrées tient à jour, alors deux morceaux de code
retiendraient le même fait, et tôt ou tard l'un d'eux se tromperait. Quand vous
avez le choix entre demander et retenir, demandez.
[source,lua]
----
overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name)
----
`overlayPrint` compte en cellules de caractère, comme depuis la leçon un, donc
l'invite est placée à l'œil : la colonne cinquante-quatre est à peu près au
milieu pour des mots de cette longueur. C'est à peu près, pas juste, et
« PRESS RIGHT » finit trois caractères plus à droite que « PRESS UP ». La leçon
dix-neuf charge une vraie police, et avec elle la possibilité de mesurer une
chaîne en pixels et de la centrer correctement.
[source,lua]
----
function onInputPressed(what)
if runOver then
if what == SWITCH_START1 then
startRun()
end
return
end
----
Un seul callback, deux tâches complètement différentes, choisies par `runOver`.
Pendant la partie, les directions veulent dire des mouvements ; une fois qu'elle
est finie, la seule touche qui veut dire quelque chose, c'est Start. Une
variable qui décide quelles règles s'appliquent est un *état*, le nom que lui a
donné la leçon douze, et un jeu n'est en grande partie qu'un petit nombre
d'états.
[#_try_it_18]
=== Essayez
. *Rendez-le plus difficile.* Changez le `last` du premier mouvement de `105` en
`75`. Quinze images, c'est une demi-seconde. Jouez et voyez si vous y
arrivez.
. *Ajoutez un mouvement.* Mettez une cinquième ligne dans la table avec une
fenêtre entre les images `370` et `400`, et montez `LAST_FRAME` à `418`. Vous
ne devriez avoir aucune autre ligne à toucher. Si vous devez le faire,
quelque chose dans le code en sait trop sur la table.
. *Notez la vitesse.* Donnez plus de points pour une réponse rapide. Dans
`onInputPressed`, là où la bonne réponse est comptée, calculez `left` et
`span` à partir de `move` et de `discGetFrame()` de la même façon que la
jauge, et ajoutez `RIGHT_SCORE * left // span` au lieu de `RIGHT_SCORE`.
Décidez si ça rend le jeu meilleur ou seulement plus agité.
. *Donnez à une mauvaise réponse sa propre conséquence.* Au lieu de terminer la
partie, faites qu'un mauvais appui coûte cinquante points et laissez la
fenêtre ouverte pour réessayer. Vous devrez arrêter de remplir `move.answer`,
et vous découvrirez pourquoi on le remplissait au départ.
. *Deux invites à la fois.* Faites se chevaucher les fenêtres de deux mouvements
et voyez ce qui se passe. `moveAt` renvoie la première correspondance, donc un
seul mouvement est demandé à la fois. Décidez de ce que vous voudriez, et de
ce qu'il faudrait pour y arriver.
[#_break_it_on_purpose_17]
=== Cassez-le exprès
Supprimez le `break` de la boucle qui trouve les mouvements manqués, et manquez
exprès le premier. La partie se termine, le disque saute, et tout a l'air
correct.
Maintenant, mettez `debugPrint(move.name .. " missed")` à côté de
`move.answer = "late"` et recommencez. La console affiche une ligne, comme vous
vous y attendez.
Puis retirez aussi le `if not runOver then` autour de la boucle, et manquez de
nouveau le premier mouvement :
----
LEFT missed
UP missed
RIGHT missed
DOWN missed
----
La première ligne arrive quand la fenêtre se ferme, comme avant. Les trois
autres arrivent ensemble dès l'image suivante, à l'instant où le disque atterrit
à 388 : chaque fenêtre restante est maintenant dans le passé, donc chaque
mouvement sans réponse est manqué d'un coup, et chacun appelle `endRun` et
refait un saut. Le jeu marche encore plus ou moins, et c'est ce qui le rend
dangereux. Trois sauts de trop en une seule image, trois réécritures du verdict,
et rien à l'écran ne le dit.
Il n'y a pas de message d'erreur ici non plus. La leçon, c'est que ce `break` et
ce garde-fou ne sont pas de la coquetterie. Ce sont les deux lignes qui disent
« c'est déjà décidé », et du code qui continue de décider quelque chose qu'il a
déjà décidé est l'une des sources de bugs les plus fiables que vous écrirez
jamais.
[#_what_you_learned_18]
=== Ce que vous avez appris
* Un quick-time event, c'est une invite, une fenêtre, et une conséquence.
* Mesurez la fenêtre en images, parce que le film est l'horloge et qu'il ne doit
jamais y avoir qu'une seule horloge.
* Gardez les mouvements dans une table d'enregistrements, pour qu'ajouter un
mouvement, ce soit ajouter une ligne.
* Juste, faux et pas de réponse sont trois issues, et elles mènent généralement
à trois endroits différents.
* `nil` dans un champ d'enregistrement est un vrai état : personne n'a encore
répondu.
* Une seule fonction répond à « quel mouvement est ouvert », et tout le monde lui
pose la question plutôt que d'en garder une copie.
* Les garde-fous -- un `return` précoce pour les cas qui ne vous intéressent pas
-- évitent d'imbriquer le vrai travail.
* `//` divise et jette la partie fractionnaire ; multipliez avant de diviser.
* `overlayBox` dessine un contour, donc une barre pleine est une pile d'appels à
`overlayLine`.
* Un mouvement manqué est un embranchement, et `discSkipToFrame` le prend,
exactement comme à la leçon seize.
[#_next_time_18]
=== La prochaine fois
La troisième partie est terminée. Vous savez lire du film, l'attendre, bifurquer
dessus, viser dedans, et chronométrer le joueur contre lui. Ce que vous avez
écrit par-dessus tout ça, c'est la police de console du moteur, qui fait six
pixels de large et qui est faite pour le diagnostic. La leçon dix-neuf charge
une vraie police, la mesure, et donne aux mots à l'écran l'air de faire partie
du jeu.