[#_lesson_17_light_guns] == Leçon 17 : Les pistolets optiques image::learn/17-light-guns.png[La leçon terminée, 480] Vous allez faire un stand de tir. Des cibles apparaissent par-dessus la vidéo aux images que vous choisissez, vous visez l'écran et vous appuyez sur une gâchette, et celles que vous touchez rapportent des points. Vous n'avez pas besoin d'un pistolet optique pour écrire ceci. Il vous faut une souris, parce que pour Singe, un pistolet optique *est* une souris : il signale une position dans les mêmes coordonnées, par les mêmes appels, et sa gâchette arrive comme le même contact qu'un bouton de souris. Écrivez le jeu pour la souris qui est sur votre bureau et il marche sur une borne où un pistolet est boulonné, et c'est la seule façon raisonnable de s'y prendre. Un pistolet n'est pas une autre sorte d'entrée. C'est une souris que quelqu'un a pointée. [#_what_you_are_pointing_at] === Ce que vous visez Commencez comme à la leçon quatorze, avec la vidéo que le moteur a déballée pour vous : ---- Singe -R -v Singe/menuBackground.mkv gallery ---- Ces images, c'est une grille violette et un coucher de soleil. Ce n'est pas un braquage de banque. Chaque numéro d'image de cette leçon s'y rapporte, pour que le code tourne le jour même où vous le tapez, et le clip entier fait 720 sur 480 et environ 420 images, ce qui, à une trentaine d'images par seconde, fait quatorze secondes. Imaginez de meilleures images. Le code se moque de ce qu'il y a dans l'image. [#_match_the_overlay_to_the_video] === Accordez l'overlay à la vidéo Mettez ceci en haut de `gallery.singe`, sous la ligne `dofile` : [source,lua] ---- overlaySetResolution(discGetWidth(), discGetHeight()) ---- Sans cet appel, l'overlay fait la *moitié* de la vidéo dans chaque direction -- 360 sur 240 ici -- et chaque coordonnée avec laquelle vous dessinez, et chaque position de souris qu'on vous donne, est dans cet espace plus petit. Le petit overlay n'a rien de mal, mais c'est une conversion de plus à garder en tête pendant que vous cherchez où un tir est tombé. Accorder l'overlay à la vidéo veut dire qu'une cible à `x = 80` est à quatre-vingts pixels du bord gauche de l'image, et un tir à `x = 80` aussi. L'entrée de la Référence consacrée à `overlaySetResolution` explique ce que ça coûte. [#_where_the_gun_is_pointing] === Où vise le pistolet Un seul appel y répond : [source,lua] ---- local x, y = mouseGetPosition(0) ---- `mouseGetPosition` renvoie deux nombres, ce que vous n'avez encore jamais vu une fonction faire et qui ne pose aucun problème à Lua : une fonction peut renvoyer autant de valeurs qu'elle veut, et vous les attrapez en écrivant autant de noms à gauche du `=`. Le `0` indique *quel* appareil de pointage vous interrogez, en comptant à partir de zéro. Avec une seule souris, ou un seul pistolet, la réponse est toujours l'appareil `0`. Les nombres sont en coordonnées de l'overlay, pas en pixels du bureau, et c'est pourquoi l'appel ci-dessus valait la peine. La position est la dernière que l'appareil a signalée, donc vous pouvez la demander quand vous voulez, aussi souvent que vous voulez. Demandez-la dans `onOverlayUpdate` et dessinez avec la réponse. Il y a une autre façon d'obtenir les mêmes nombres. `onMouseMoved` est un callback, comme `onOverlayUpdate`, et le moteur l'appelle chaque fois qu'un pointeur bouge, en lui donnant la nouvelle position. C'est le bon choix quand vous voulez *réagir* au mouvement. Pour dessiner un réticule, ce n'est pas le cas : vous voulez savoir où se trouve la chose en ce moment, et `mouseGetPosition` vous le dit sans que vous ayez à en garder une copie. [#_drawing_a_crosshair] === Dessiner un réticule Le moteur n'en dessinera pas pour vous. Un réticule, c'est quatre traits et un cercle : [source,lua] ---- local function drawCrosshair(x, y) colorForeground(255, 255, 255, 255) overlayCircle(x, y, 10) overlayLine(x - 18, y, x - 4, y) overlayLine(x + 4, y, x + 18, y) overlayLine(x, y - 18, x, y - 4) overlayLine(x, y + 4, x, y + 18) end ---- Les vides comptent plus que les traits. Arrêter les quatre branches avant le cercle vous laisse un trou au milieu pour voir la cible à travers, ce qui est tout l'intérêt d'un réticule et la première chose que les débutants recouvrent. Il y a une chose à demander avant de le dessiner : [source,lua] ---- local drawAim = singeWantsCrosshairs() ---- Cet appel renvoie `false` quand le joueur a lancé Singe avec `--nocrosshair`. Certains pistolets optiques affichent leur propre point à l'écran, et deux réticules à quelques pixels l'un de l'autre, c'est pire qu'aucun. La réponse ne peut pas changer pendant que le jeu tourne, alors lisez-la une fois en haut et gardez-la dans une variable. Vous n'avez pas à cacher le pointeur du système, parce que Singe l'a déjà fait. Il capture la souris au démarrage, ce qui la confine à la fenêtre et cache la flèche du bureau. Si vous voulez un jour la récupérer -- pour un éditeur de niveaux, par exemple -- `mouseSetCaptured(false)` la libère, et la touche affectée à `INPUT_GRAB`, qui est `G` dans les commandes fournies, bascule la même chose pendant que vous jouez. [#_the_trigger] === La gâchette Une gâchette est un bouton, et les boutons arrivent dans `onInputPressed`, que vous avez rencontré pour la première fois à la leçon trois : [source,lua] ---- function onInputPressed(what, device) if what ~= SWITCH_BUTTON3 then return end local x, y = mouseGetPosition(device or 0) -- ... end ---- `SWITCH_BUTTON3` est le bouton gauche de la souris, et sur un pistolet, c'est la gâchette. Le `controls.cfg` fourni affecte le bouton gauche à `SWITCH_BUTTON3`, le droit à `SWITCH_BUTTON1`, et celui du milieu à `SWITCH_BUTTON2`. Ces numéros ont l'air mélangés parce qu'ils le sont : les contacts sont les boutons du panneau d'arcade, et savoir quelle commande physique est câblée à quel contact, c'est l'affaire de la borne, pas du jeu. Maj gauche est aussi affectée à `SWITCH_BUTTON3`, pour que vous puissiez tirer sans souris pendant vos tests. Le second argument est la partie nouvelle. Quand le contact vient d'une souris ou d'un pistolet optique, `device` est l'indice de cet appareil -- `0` pour le premier, `1` pour le second, et ainsi de suite. Quand il vient d'une touche ou d'une manette, il vaut `nil`, parce qu'aucun appareil de pointage n'était en jeu. C'est pourquoi l'appel ci-dessus dit `device or 0` : si `device` vaut `nil`, utilisez `0` à la place. Vous utiliserez `or` comme ça sans arrêt, et ça se lit exactement comme ça sonne en anglais -- « device, ou rien, auquel cas zéro ». C'est ce seul argument de plus qui rend possible une borne à deux pistolets. Le pistolet du joueur un est l'appareil `0` et celui du joueur deux est l'appareil `1`, et le même `onInputPressed` les distingue sans que vous ayez à deviner. [#_did_it_hit_anything] === Est-ce que ça a touché quelque chose Vous savez déjà répondre à ça. C'est la leçon onze : [source,lua] ---- if collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then ---- Un tir est un point. Une cible est un rectangle. `collidePointRect` demande si le point est à l'intérieur du rectangle, et le bord compte comme l'intérieur. Tirer n'a rien de spécial. Le test d'impact d'un jeu de tir est le même que celui qu'utilise un jeu de plateforme pour savoir si vous avez atterri sur un bloc, et la famille `collide` de la Référence a le reste des formes. [#_targets_that_come_and_go] === Des cibles qui vont et viennent La seule chose que la vidéo change, c'est *quand* une cible existe. À la leçon onze, un rocher restait là jusqu'à ce que vous tiriez dessus. Ici, une cible est à l'écran pendant que le film montre la partie de la scène à laquelle elle appartient, et ensuite elle disparaît, que vous l'ayez touchée ou non. Ce sont deux champs de plus dans l'enregistrement, et la leçon six vous a fabriqué l'outil : [source,lua] ---- local targets = { { first = 40, last = 110, x = 80, y = 110 }, { first = 120, last = 190, x = 470, y = 140 }, { first = 200, last = 270, x = 290, y = 250 }, { first = 280, last = 360, x = 150, y = 300 }, } ---- Quatre enregistrements, chacun avec l'image où la cible apparaît, l'image où elle s'en va, et l'endroit où elle se trouve. Ajouter une cinquième cible, c'est ajouter une ligne. C'est la forme à adopter chaque fois que vous vous surprenez sur le point d'écrire les quatre mêmes lignes de code avec des nombres différents dedans. Savoir si une cible compte est alors une petite fonction à part entière : [source,lua] ---- local function targetIsUp(target, frame) return not target.hit and frame >= target.first and frame <= target.last end ---- Le dessin lui pose la question, et le tir aussi, et c'est tout l'intérêt de la mettre à un seul endroit : une cible que vous voyez et une cible que vous pouvez toucher ne peuvent jamais être en désaccord. [#_the_whole_thing_9] === Le tout [source,lua] ---- -- Apprendre à programmer avec Singe -- Leçon 17 : Les pistolets optiques dofile("Singe/Framework.singe") overlaySetResolution(discGetWidth(), discGetHeight()) local FIRST_FRAME = 20 local LAST_FRAME = 400 local TARGET_SIZE = 96 local HIT_SCORE = 100 local FLASH_TIME = 12 local targets = { { first = 40, last = 110, x = 80, y = 110 }, { first = 120, last = 190, x = 470, y = 140 }, { first = 200, last = 270, x = 290, y = 250 }, { first = 280, last = 360, x = 150, y = 300 }, } local aimX = overlayGetWidth() / 2 local aimY = overlayGetHeight() / 2 local score = 0 local shots = 0 local hits = 0 local flash = 0 local flashX = 0 local flashY = 0 local drawAim = singeWantsCrosshairs() local function drawCrosshair(x, y) colorForeground(255, 255, 255, 255) overlayCircle(x, y, 10) overlayLine(x - 18, y, x - 4, y) overlayLine(x + 4, y, x + 18, y) overlayLine(x, y - 18, x, y - 4) overlayLine(x, y + 4, x, y + 18) end local function targetIsUp(target, frame) return not target.hit and frame >= target.first and frame <= target.last end local function startRound() score = 0 shots = 0 hits = 0 flash = 0 for _, target in ipairs(targets) do target.hit = false end discSkipToFrame(FIRST_FRAME) end function onInputPressed(what, device) if what == SWITCH_START1 then startRound() return end if what ~= SWITCH_BUTTON3 then return end local x, y = mouseGetPosition(device or 0) local frame = discGetFrame() shots = shots + 1 for _, target in ipairs(targets) do if targetIsUp(target, frame) and collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then target.hit = true hits = hits + 1 score = score + HIT_SCORE flash = FLASH_TIME flashX = x flashY = y return end end end function onOverlayUpdate() local frame = discGetFrame() if frame >= LAST_FRAME then startRound() frame = FIRST_FRAME end aimX, aimY = mouseGetPosition(0) overlayClear() for _, target in ipairs(targets) do if targetIsUp(target, frame) then colorForeground(255, 200, 0, 255) overlayBox(target.x, target.y, target.x + TARGET_SIZE - 1, target.y + TARGET_SIZE - 1) overlayCircle(target.x + TARGET_SIZE / 2, target.y + TARGET_SIZE / 2, TARGET_SIZE / 3) end end if flash > 0 then colorForeground(255, 255, 255, 255) overlayCircle(flashX, flashY, 30 - flash * 2) flash = flash - 1 end overlayPrint(2, 1, "SCORE " .. score .. " HITS " .. hits .. "/" .. shots .. " FRAME " .. frame) if drawAim then drawCrosshair(aimX, aimY) end return OVERLAY_UPDATED end startRound() ---- Visez une boîte jaune et cliquez. Le compteur dans le coin augmente, un anneau blanc s'ouvre là où vous avez touché, et la boîte disparaît. Ratez, et seul le compteur de tirs augmente, ce qui est l'afficheur de scores le moins cher qui soit et en dit plus à un joueur sur sa forme que le score. La manche recommence d'elle-même quand le film arrive au bout, et `1` la relance quand vous voulez. [#_what_just_happened_13] === Ce qui vient de se passer [source,lua] ---- local frame = discGetFrame() if frame >= LAST_FRAME then startRound() frame = FIRST_FRAME end ---- La vidéo est l'horloge, comme à la leçon quatorze, et `discGetFrame` est la façon de la lire. Tout le reste de l'image est décidé à partir de ce seul nombre. Quand le film dépasse la fin de la dernière cible, la manche recommence : `startRound` remet le score à zéro, marque chaque cible comme non touchée, et renvoie le disque au début avec `discSkipToFrame`. La ligne en dessous règle aussi la copie locale de `frame`, parce qu'elle a été lue avant le saut et contient encore l'ancien nombre. Sans elle, le dessin plus bas passerait une image à travailler avec un nombre qui n'est plus vrai. [source,lua] ---- shots = shots + 1 for _, target in ipairs(targets) do if targetIsUp(target, frame) and collidePointRect(...) then ---- Comptez le tir avant de chercher une touche, pour qu'un tir raté compte aussi. Puis parcourez la liste. Le `return` dans la boucle s'arrête à la première cible touchée, ce qui veut dire qu'une balle ne peut pas abattre deux cibles qui se chevauchent -- une règle que vous devez trancher dans un sens ou dans l'autre, et c'est cette ligne qui la tranche. [source,lua] ---- overlayBox(target.x, target.y, target.x + TARGET_SIZE - 1, target.y + TARGET_SIZE - 1) ---- `overlayBox` veut deux coins opposés, pas un coin et une taille, donc le coin éloigné est le coin proche plus la taille. Le `- 1` est là parce que les deux coins sont inclus : une boîte de `80` à `175` fait quatre-vingt-seize pixels de large. `collidePointRect` veut au contraire un coin et une taille, c'est pourquoi `TARGET_SIZE` y entre tout seul et qu'ici il est ajouté au coin. Confondre les deux est un bug vraiment courant, et il se voit sous forme de cibles que vous pouvez toucher un peu en dehors de l'endroit où elles sont dessinées. [source,lua] ---- if flash > 0 then colorForeground(255, 255, 255, 255) overlayCircle(flashX, flashY, 30 - flash * 2) flash = flash - 1 end ---- `flash` est un compte à rebours en images, mis à douze quand un tir fait mouche et diminué de un chaque fois que l'écran est dessiné. Comme le rayon est calculé *à partir* du compte à rebours, l'anneau grandit à mesure que le nombre diminue. Douze images, c'est moins d'une demi-seconde. Un retour visuel qui dure plus longtemps que ça cesse de ressembler à une conséquence de la gâchette et commence à ressembler à la météo. [#_two_guns] === Deux pistolets Vous n'êtes qu'à deux changements d'une borne à deux joueurs, et aucun des deux n'est dans la boucle du jeu. Le premier est `mouseSetMode(MOUSE_MANY)`, qui dit à Singe de lire chaque appareil séparément au lieu de les réunir en un seul curseur. `mouseHowMany()` dit combien il en a trouvé au démarrage, donc un jeu demande normalement avant de s'engager : [source,lua] ---- if mouseHowMany() >= 2 then mouseSetMode(MOUSE_MANY) end ---- Après ça, `mouseGetPosition(1)` est le second pistolet, et l'argument `device` de `onInputPressed` dit quel pistolet a tiré. Gardez deux réticules, deux scores, et une seule liste de cibles, et vous avez un jeu coopératif. Le second changement n'est pas du tout dans votre script. Le `controls.cfg` fourni n'affecte que les boutons de la *première* souris, donc la gâchette du second pistolet n'atteint rien. Une borne à deux pistolets a besoin dans ce fichier de sa propre ligne, qui nomme les deux : [source,lua] ---- INPUT_ACTION_3 = { SCANCODE.LSHIFT, GAMEPAD_0.BUTTON_X, MOUSE_0.BUTTON_LEFT, MOUSE_1.BUTTON_LEFT } ---- Dites-le dans vos instructions. Un joueur dont le second pistolet ne fait rien accusera votre jeu, et il aura à moitié raison. [#_the_other_way_to_test_a_hit] === L'autre façon de tester un tir Il y a une seconde façon de répondre à « ce tir a-t-il touché quelque chose ? », et elle vaut d'être connue même si vous n'allez pas vous en servir aujourd'hui. `vldpGetPixel(x, y)` lit la couleur d'un pixel de l'image vidéo actuellement à l'écran. Des jeux s'en servent depuis la toute première version de Singe : peignez chaque cible, dans les images, d'une couleur qui n'apparaît nulle part ailleurs, et à chaque pression sur la gâchette, demandez quelle couleur se trouve sous le réticule. Ça vous offre des zones d'impact qui suivent exactement l'action, gratuitement, sans aucun rectangle à définir. Ça vous coûte des images qu'il faut préparer de cette façon, et un test d'impact que vous ne pouvez ni voir ni déboguer. L'entrée de la Référence consacrée à `vldpGetPixel` donne les détails. Les rectangles sont le bon choix par défaut ; gardez ceci sous le coude. [#_what_only_a_cabinet_cares_about] === Ce qui ne compte que pour une borne Tout ce qui précède marche sur votre bureau. Plusieurs choses n'existent pas du tout sur votre bureau, et c'est le bon moment pour les nommer, pour qu'elles ne soient pas une surprise plus tard. *Le calibrage.* Il faut dire à un vrai pistolet optique où se trouve l'écran, parce que c'est une caméra qui regarde un moniteur depuis l'endroit où se tient le joueur. Les outils de service de Singe ont un écran Light Gun qui place des cibles au milieu et à chaque coin et marque l'endroit que le pistolet a vraiment visé, pour que vous voyiez dans quel sens il est décalé. Votre jeu ne fait pas le calibrage et ne devrait pas essayer. *La bordure Sinden.* Une famille de pistolets répandue trouve l'écran en cherchant un cadre lumineux dessiné autour de l'image. `--sindengun` dessine ce cadre, et le moteur ramène les positions dans l'image plus petite qui se trouve à l'intérieur, pour que votre jeu continue de recevoir des coordonnées qui veulent dire ce qu'elles voulaient dire avant. *Les écrans qui n'ont pas la forme du film.* `ratioGetX()` et `ratioGetY()` vous donnent les nombres qu'un joueur a passés en ligne de commande pour un écran dont les proportions ne correspondent pas aux images. Singe ne les applique pas ; un jeu qui en veut fait le calcul lui-même. *Les pistolets qui s'en vont.* `SWITCH_MOUSE_DISCONNECT` arrive dans `onInputPressed` quand un pistolet est débranché. Aucun bouton ne le produit ; c'est le moteur qui le déclenche. Un jeu qui le guette peut afficher « PLAYER 2 GUN DISCONNECTED » à l'écran au lieu d'avoir l'air d'être mort. *Pas de souris du tout.* Une borne avec un joystick et sans appareil de pointage peut piloter le curseur depuis le stick, ce que `joyMouseEnable` active. Rien de tout ça ne change le jeu que vous avez écrit. Tout ça, c'est la leçon trente et un. [#_try_it_17] === Essayez . *Déplacez une cible.* Changez le `x` de la troisième cible en `680` et lancez. Elle déborde maintenant en bonne partie du bord droit, parce que l'overlay fait 720 de large et la cible 96. Vous pouvez toujours tirer sur le petit bout qui reste. Décidez de ce que vous voulez qu'il se passe sur les bords. . *Rendez les cibles plus faciles.* Changez `TARGET_SIZE` en `140`. Remarquez que le dessin et le test d'impact changent tous les deux, et que vous n'avez modifié qu'un seul nombre. C'est à ça que sert une constante nommée. . *Comptez la précision.* Ajoutez une ligne qui affiche `hits` en pourcentage de `shots`. Protégez-vous contre une division par zéro avant le premier tir, et découvrez ce que Lua affiche si vous ne le faites pas. . *Faites qu'une cible vous punisse.* Ajoutez un champ `penalty = true` à un enregistrement et retirez des points quand cette cible est touchée. Remarquez que vous ajoutez un champ à un seul enregistrement, pas aux quatre, et que ceux qui ne l'ont pas y ont `nil`. . *Retirez le `return`* de la boucle dans `onInputPressed`, puis faites se chevaucher deux cibles en leur donnant les mêmes images et presque la même position. Un seul tir les abat maintenant toutes les deux. Décidez du comportement que veut votre jeu. [#_break_it_on_purpose_16] === Cassez-le exprès Changez le test de tir pour qu'il utilise `SWITCH_BUTTON1` au lieu de `SWITCH_BUTTON3` et lancez. Rien ne casse. Aucune erreur n'apparaît. Le bouton gauche ne fait plus rien, et c'est le bouton *droit* qui tire maintenant. C'est la pire sorte de bug, et la plus courante dans le code des entrées : le programme n'est pas faux, il est juste branché sur la mauvaise chose. Il n'y a pas de message d'erreur à lire, parce que vous avez posé une question sensée sur un contact qui existe, et il a répondu honnêtement. Deux choses permettent de le trouver. La première est `debugPrint`, de la leçon huit : [source,lua] ---- function onInputPressed(what, device) debugPrint("switch " .. what .. " from device " .. tostring(device)) ---- Maintenant, chaque appui affiche une ligne, et vous pouvez voir ce que la gâchette envoie vraiment. Remarquez `tostring(device)` : `device` vaut `nil` pour un appui sur une touche, et coller `nil` à une chaîne avec `..` est une erreur. `tostring` le transforme en mot `nil` et la ligne s'affiche. La seconde est l'Input Test du moteur, dans les outils de service, qui montre chaque appareil qu'il voit et le dernier contact que le jeu aurait reçu. Quand un bouton ne fait rien, cet écran vous dit quelle moitié du problème vous avez : soit le contact arrive et votre jeu l'ignore, soit il n'arrive pas du tout. [#_what_you_learned_17] === Ce que vous avez appris * Un pistolet optique se présente comme une souris, donc un jeu écrit pour la souris marche avec un pistolet. * `mouseGetPosition(device)` renvoie deux valeurs, la position du pointeur en coordonnées de l'overlay. * Une fonction Lua peut renvoyer plus d'une valeur, et vous les attrapez en écrivant autant de noms. * `singeWantsCrosshairs()` dit s'il faut dessiner votre propre réticule. Lisez-le une fois. * Singe capture et cache le pointeur du système au démarrage ; vous n'avez pas à le faire. * `onInputPressed` reçoit un second argument qui indique quelle souris ou quel pistolet a tiré, et `nil` quand c'était une touche ou une manette. * Un test d'impact contre un rectangle, c'est `collidePointRect`, la même collision que celle que vous avez apprise à la leçon onze. * La fenêtre d'images d'une cible, ce ne sont que deux champs de plus dans son enregistrement. * `a or b` vous donne `b` quand `a` vaut `nil`. * Le calibrage, la bordure Sinden et le câblage à deux pistolets vivent en dehors de votre script. [#_next_time_17] === La prochaine fois Votre stand de tir demande une seule chose : touchez ça, là. L'autre convention d'arcade sur laquelle reposent les jeux en vidéo demande quelque chose de plus difficile -- appuyez sur *ceci*, *maintenant*, et vous avez une demi-seconde. La leçon dix-huit construit un quick-time event, avec une invite, une fenêtre mesurée en images, et trois fins différentes selon que vous avez eu raison, que vous avez eu tort, ou que vous avez été trop lent.