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