Eléments pour un Jeu d'Arcades de Tony Scott Sous l'apparente simplicité du résultat, la programmation d'un jeu d'arcades peut être une tentative décourageante. Même le jeu le plus simple intègre les différentes possibilités propres à la partie matérielle de l'Amiga. Le graphisme, le son et l'interfaçage avec la partie matérielle du monde réel doivent être tous réunis dans un logiciel de petite taille, qui soit rapide et intéressant. Les composants élémentaires nécessaires à la construction d'un jeu complet sont éparpillés dans des douzaines de livres, de manuels et de tutoriels. Le fait d'avoir à trouver ces minuscules parcelles et de devoir les intégrer dans un programme complet peut être, dans le meilleur des cas, frustrant. Tout en n'étant aucunement exhaustif, cet article illustre comment combiner plusieurs de ces éléments dans un ensemble cohérent. Attention : plusieurs des méthodes employées ici sont des "adversaires du multitâche", en prenant à la base la relève de la machine (ou tout au moins en empêchant Intuition de voir les événements en entrée). Je ne recommande pas plusieurs de ces méthodes pour autre chose que pour des jeux car de telles actions sont considérées comme un comportement inacceptable dans des logiciels d'application. Si vous pensez écrire un programme multitâche, orienté utilisateur, voyez ci-dessous la section "Améliorations : ne vous arrêtez pas maintenant". LES TYPES. Avant denous plonger dans le programme d'exemple écrit en C SAS (game.c dans le tiroir Scott), établissons les variables de structure dont nous avons besoin. Pour la manette de comande, nous avons : JOYDAT x Etat gauche/droit de la manette de jeu y Etat haut/bas de la manette de jeu fire Etat du bouton de mise à feu Pour les variables du jeu, nous employons : OBJECT x,y Coordonnés cartésiennes de l'objet dx,dy Vitesse de l'objet rot Rotation courante de l'objet thrusting Etat du coup (activé ou désactivé) lastshot Heure du dernier tir sur l'objet A partir de maintenant, il pourra être utile de lire l'article en même temps que le programme source game.c. (Les utilisateurs du C Manx devront noter : comme ce compilateur n'accepte pas le mot réservé _chip, on devra réserver un bloc de mémoire 'chip', copier dans celui-ci les données du graphisme et se servir plutôt des données allouées que des celles définies dans le programme lui-même.) VIDEO : LA MANIERE DIFFICILE. L'élément plus important d'un jeu vidéo est la partie visuelle. La partie électronique de l'Amiga offre deux méthodes pour créer une animation rapide et harmonieuse. La première méthode, et la plus populaire, celle qui emploie les 'bobs' et les lutins des circuits, a été largement traitée. La seconde est plus compliquée et est souvent traitée avec des accents mystérieux, pleins de crainte révérentielle et de mysticisme. Ce tour de magie, qui s'appelle le "double buffering" n'est rien d'autre qu'un simple tour de main. Dans cet exemple particulier, l'emploi des lutins des registres aurait été plus facile à mettre en oeuvre, mais, pourillustrer plusieurs concepts fondamentaux, j'ai choisi les appels au Blitter et le "double buffering". Ce dernier est une espèce de commutation entre deux zones d'affichage différentes pour que le spectateur ne s'aperçoive pas de la façon dont on réalise le rendu du graphisme. Cette technique évite le clignotement, le scintillement ou une animation visible. Le procédé est relativement simple. Tout d'abord, on crée un affichage élémentaire, constitué d'un 'view', de sa structure RasInfo, d'un 'wiewport' et d'une mappe de bits, en même temps qu'on crée une mappe de bits de rechange qu'on utilisera par la suite. Après avoir initialisé et lié ces structures ensemble, des appels à MakeVPort(), à MrgCop() et à LoadView() mettent en place l'affichage et compilent les listes Copper appropriées. Pour la partie "double buffering" de l'affichage, on sauvegarde une copie de ces listes Copper, puis on force le système à en faire un nouvel ensemble pour la deuxième mappe de bits. Ce nouvel ensemble est créé en mettant les deux mappes de bits à la valeur NULL et en faisant pointer RasInfo->BitMap sur la mappe de bits en réserve, et en appelant ensuite à nouveau MrgCop(). On crée un 'rastport' pour pouvoir utiliser dans l'affichage les fonctions de masque du Blitter ainsi que Text(). Ce 'rastport' est constamment modifié et emploie donc la mappe de bits qui n'est pas couramment affichée (c'est-à-dire "savebm"). La Planche 1 illustre la technique du "double buffering". Description Première Mappe de Bits Seconde Mappe de Bits ______________________________________________________________________ |1 | | | | On fait le rendu de | ____________ | ____________ | | la première mappe de | | | | | | | | bits, tandis qu'on | | Faire le | | | | | | affiche le 'view' | | rendu | | | | | | précédent (le plus | | ici. | | | | | | vraisemblablement | |____________| | |____________| | | celui du Workbench). | | | |_______________________|_______________________|______________________| |2 | ______________ | | | On affiche la première| / ____________ \ | ____________ | ==> | mappe de bits, tandis | / | | \ | | | | | | qu'on fait le rendu | | | | | | | Faire le | | | | de la seconde mappe. | | | | | | | rendu | | | | | | | | | | | ici. | | | | | \ |____________| / | |____________| | | | | \______________/ | | | |_______________________|_______________________|______________________| | |3 | | ______________ | | | On affiche la seconde | ____________ | / ____________ \ | | | mappe de bits, tandis | | | | / | | \ | | | qu'on fait le rendu | | Faire le | | | | | | | | | de la première mappe. | | rendu | | | | | | | | | | | ici. | | | | | | | <== | Revenir au point | |____________| | \ |____________| / | | n.o 2 | | \______________/ | |_______________________|_______________________|______________________| Notes : ______ / ____ \ Indique la mappe de bits qui s'affiche actuellement sur le | |____| | moniteur. \______/ Dans le programme véritable, "savebm" et "newbm" sont des pointeurs sur les deux mappes de bits. Planche 1. Représentation graphique du "double buffering". LE SCORE. Pour éviter de transformer les nombres en chaînes de caractères, nous faisons comencer le score à 48 (la valeur ASCII de 0) et nous continuons jusqu'à 58 (la valeur ASCII de 9), ce qui nous permet d'utiliser la fonction Text() directement pour les données du score. Cette méthode nous limite à des scores allant de 1 à 10 mais nous épargne beaucoup de code. Quand le programme appelle ShowScore(), la routine affiche le score des deux joueurs, chacun avec sa couleur propre. Etant donné que nous avons deux affichages (les deux mappes de bits du "double buffering") dont nous devons nous occuper, nous appelons SwapDisplay() et nous affichons le résultat une seconde fois. Ceci nous garantit que les deux affichages affichent le même score et ceci sans scintillement aucun. LA MINUTERIE POUR LE JEU. Pour garder trace de la durée du jeu, nous créons notre mini-minuterie. Celle-ci n'est rien d'autre qu'un WORD non signé incrémenté à chaque image. Comme nous faisons le rendu d'une nouvelle image 60 fois par seconde (la vitesse d'une durée de suppression verticale), le compteur ne retournera pas à zéro pendant 828 jours et demi. Pour le déterminer, divisez 4 294 967 295 (valeur maximale d'un ULONG) par 60 pour le nombre de secondes maximum, puis transformez les secondes en jours. 828 jours et demi, cela ferait beaucoup de temps pour un seul jeu. En employant cette minuterie, nous pouvons contrôler des éléments tels le nombre de fois qu'on peut tirer un projectile ainsi que le temps écoulé. DES JOLIES IMAGES. DoImagery() met en place une mappe de bits de 16 pixels sur 120, avec une profondeur de deux plans, contenant toute l'imagerie de chacun des différents objets graphiques dont on fera le rendu pendant le jeu. Cette mappe de bits est divisée en 21 images de 16 pixels sur 10, dont nous donnons la description ci-dessous : Images Description 0-7 L'image du bateau dans chacune des huit vues 8-11 L'image du soleil dans chacune des quatre positions 12 L'image du missile (un seul) 13-20 Le coup pour le bateau dans chacune des huit vues Etant donné que les plans de la mappe de bits sont identiques, vous pouvez atteindre trois couleurs différentes par image avec une seule mappe de bits en masquant certains plans lorsqu'on fait un 'blitting' sur l'imagerie de l'affichage. Voici ces plans : Plan 0 Plan 1 Couleur Masque OFF ON Bleu 1 ON OFF Rouge 2 ON ON Jaune 3 En utilisant BltMaskBitmapRastPort() avec le premier plan de bits comme masque, nous avons la possibilité de faire un 'blit' seulement là où se trouve le bateau, au lieu d'en faire un d'un carré de 16 pixels sur 10. Si nous souhaitons avoir des objets multicolores, nous devrons créer un plan de bits supplémentaire pour qu'il serve de masque, ainsi que huit autres images pour l'image du deuxième bateau. DERRIERE LES SCENES. Le rendu véritable se fait toujours dans la mappe de bits "savebm", pendant que la mappe "newbm" commence son affichage. Voici à peu près le processus d'un rendu : 1. Effacer la mappe de bits qu'on ne voit pas à l'affichage. 2. Faire un 'blit' sur les bateaux, sur le soleil et sur le missile dans l'affichage caché. 3. Cacher l'affichage courant et montrer l'affichage caché. 4. S'assurer que le 'rastport' utilise la mappe de bits cachée. 5. Charger le nouvel affichage et les listes Copper. 6. Revenir au point n.o 1. Nous parlerons du processus plus en détail quand nous analyserons ci-dessous chaque routine individuellement (pour la représentation du diagramme, voyez la Planche 2 ci-dessous). __________________________ | | | Mise en place de l'audio | | et de l'affichage. | |__________________________| __________________________ | | ----->| Effacer la mappe de bits | | | cachée. | | |__________________________| | __________________________ _ __________________________ | | Déplacer et faire le | O / | | | | rendu des bateaux et | u / | Réinitialiser le bateau | | | des missiles. | i / | et le missile. | | |__________________________| ?_/ |__________________________| | __________________________ __________________________ | | | | | | | Contrôle des collisions. | | Incrémenter le score. | | | | | | | |__________________________| |__________________________| | |No __________________________ | ____________|_____________ | | | | |<-------| Afficher les scores. | | | Rotation et affichage | | | | | du soleil. | |__________________________| | |__________________________| | __________________________ | | | | |Permutation de l'affichage| | | | | |__________________________| | __________________________ | | Contrôle du score et | | Non | touche 'escape' pour | ------| arrêt du jeu. | |__________________________| __________________________ | | | Nettoyage et arrêt. | | | |__________________________| Planche 2. Diagramme élémentaire de contrôle du jeu. EFFACEMENT DE LA MAPPE DE BITS : SAUVEGARDEZ LES SCORES ! En effaçant la mappe de bits, nous laissons tranquilles les dix lignes du haut, par conséquent nous n'aurons pas à réafficher le score 60 fois par seconde. Pour y arriver, nous employons BltClear() sur chaque plan de bits de la mappe de bits cachée en commençant 400 octets après le début de celle-ci (10 lignes de 40 octets chacune). LA PERMUTATION. Comme nous l'avons dit ci-dessus, nous employons deux plans de bits pour éviter que l'utilisateur ne voit pas le rendu tant qu'il n'est pas fini. On y arrive par SwapDisplay(). La mappe de bits cachée est toujours "savebm" et la mappe de bits qui s'affiche est toujours "newbm". Nous pouvons dire la même chose des listes Copper. SaveCprList est cachée, tandis que NewCprList s'affiche. Pour le rendu du texte et pour les 'blits' des masques, il est important que le 'rastport' utilise aussi la mappe de bits cachée. Après avoir réaménagé à nouveau notre structure, nous la réaffichons en appelant LoadView(). Nous faisons cela pendant la période de suppression verticale entre des appels à WaitBOVP() et à WaitTOF() pour éviter des incidents lors de l'affichage. LE SON : ENCORE LE SON ? A cause de l'abondance d'informations disponibles sur le chargement et sur l'exécution d'échantillons sonores au format 8SVX, je n'entrerai pas dans le détail ici. Les routines pour le son présentes s'appuient sur un type défini qui s'appelle "Sound", qui renferme toutes les informations nécessaires à charger et à jouer des sons échantillonnés. LoadSample() charge un son échantillonné à partir d'un nom de fichier donné. PlaySample() joue un échantillon donné sur le canal audio indiqué. FreeSample() libère la mémoire allouée à un échantillon donné. Le champ 'repeat' détermine combien de fois on devra répéter un échantillon à chaque fois que le programme appelle PlaySample(). Si on met le champ à 0, l'échantillon se répète à l'infini (ou tout au moins jusqu'à ce que vous ne l'arrêtiez avec StopChannel()). Nous faisons exécuter "à l'infini" le son du coup, nous n'avons donc qu'à le faire commencer et à l'arrêter dès que le joueur le décide. Ceci permet d'entendre l'échantillon du son en continu et non par à coups. Notez aussi, s'il vous plaît, que toutes ces fonctions exigent qu'on appelle d'abord la routine GrabAllChannels() pour éviter la visite de l'ami Gourou. LES LUTINS : MAIS VOUS N'AVIEZ PAS DIT QUE... Non, nous n'utiliserons pas les lutins dans cet exemple. Nous appelons OFF_SPRITE pour nous débarasser de ce fichu pointeur d'Intuition. A la fin du programme, nous appelons gentiment ON_SPRITE, au cas où quelqu'un voudrait utiliser à nouveau le pointeur de la souris. LA MANETTE DE COMMANDE : OH JOIE ! La routine fournie pour la manette de commande s'appuie sur la routine en assembleur écrite par Rhett Anderson ("Amiga World Tech Journal", n° 1, p. 34) pour "En quête de... La routine parfaite pour la manette de comande". En quelques mots, nous lisons directement les registres du circuit intégré concerné, appliquons un masque sur les bits adéquats et utilisons ces valeurs comme d'un index pour consulter les tables 'tablex' et 'tabley'. Pour des renseignements déatillés, consultez l'article cité. LE GESTIONNAIRE LOGIQUE D'ENTREE : SOYONS RADINS. Etant donné que nous cherchons la vitesse et que nous ne voulons pas qu'Intuition vienne se mêler de nos boutons de mise à feu en pensant qu'il s'agit de pressions sur les boutons de la souris, nous nous emparons du flux de l' 'input.device'. Avec notre gestionnaire (qui s'appelle KeyHandler), nous vérifions pour voir si l'on appuie sur la touche 'Escape'. Si tel est le cas, nous arrêtons le jeu et nettoyons toute notre pagaïe. S'il s'agit d'un tout autre événement, nous l'avalons dans son intégralité pour éviter qu'Intuition ne le reçoive. Un appel à RestoreInputChain() supprime notre gestionnaire et soulage Intuition de sa perte sensorielle. Cette méthode, alternative à l'emploi du port IDCMP d'Intuition, présente l'avantage de ne pas avoir à exécuter un Wait() ou un GetMsg() mouvementé, tout en libérant une puissance d'exécution considérable. MOUVEMENT : RALENTISSEZ LE MOUVEMENT POUR SIMULER LA VITESSE. Etant donné que nous avons une résolution d'écran limitée, nous devons représenter la position et la vitesse de l'objet avec des très grands nombres, puis les réduire pour les besoins de l'affichage. Ceci nous permet de déplacer notre bateau très lentement (à un ou deux pixels par seconde) au lieu de le déplacer d'un pixel au minimum tous les 1/60e de seconde (1/60e sur une machine en PAL). Notre échelle est de 100 unités par pixel. Par conséquent, si 'ship[1].dx' est égal à 20, alors le bateau se déplacera d'un pixel sur la droite toutes les cinq images. De même, si la position du bateau est égale à 10567, 6087, alors la position véritable à l'écran sera égale à 106, 609. LA PESANTEUR : LA LOURDEUR DES MATHEMATIQUES. Comme nous voulons que le soleil obéisse aux lois de la pesanteur, nous devons simuler comment se déplacent les bateaux par rapport au soleil. La pesanteur réelle se calcule par m+r², où r est la distance entre les deux corps et m est une constante qui dépend de la masse des deux corps. Dans les faits, cette équation fonctionne assez bien (après tout, nous ne devons pas nous lancer dans l'espace), mais, pour notre jeu, les résultats sont loin d'être heureux. Si l'on emploie cette équation, le bateau est attiré trop violemment vers l'intérieur du soleil d'environ un pouce et aucunement vers les bords du soleil. Pour compenser, nous utilisons l'équation 168+r. Ceci fait de la pesanteur une fonction linéaire et plus proche de ce qu'attend l'utilisateur d'un jeu. Pourquoi 168 ? J'ai choisi 168 pour que la "poussée" vers le bord le plus éloigné (320, 200) soit au moins égale à une unité : ___________________ \/(320*320)+(200*200) = 168 La poussée est la rapidité avec laquelle l'objet est attiré vers le soleil et se distribue proportionnellement en relation avec l'éloignement des distances x et y. Actuellement, nous employons l'arithmétique en virgule flottante, en y ajoutant une fonction racine carrée entière; des nombres entiers ou à virgule fixe auraient été un meilleur choix. DEPLACEMENT DES BATEAUX : LE PORT EST DIFFICILE D'ACCES. Dans la routine DoShip(), nous déplaçons, faisons tourner, frappons et mettons à feu nos bateaux. D'abord, nous appliquons la pesanteur, puis nous lisons la manette de commande. Si l'on appuie sur la manette à gauche ou à droite, nous incrémentons la rotation du bateau. Chaque incrémentation n'est qu'un quart de ce qui est nécessaire pour faire réellement tourner le bateau dans la vue suivante. Nous avons fait cela pour rendre le contrôle du bateau moins sensible et le rendre plus proche du mouvement tel que nous le faisons réellement. Si l'on appuie sur le bouton de mise à feu et qu'on ne tire pas le missile juste après (dans les 15 dernières impulsions d'horloge du jeu), nous mettons ship[].lastshot à une valeur égale au compteur du jeu courant et mettons sa vitesse relative à la vitesse du bateau. Cette vitesse relative indique que le joueur peut jaillir dans une direction, en tirant en arrière, et peut avoir un missile qui le suit. On fait le rendu du missile par DoMissile(). Si l'on appuie sur la manette de commande, nous avons alors beaucoup de travail à accomplir. D'abord, nous trouvons les valeurs 'thrustx' et 'thrusty' dans les tables de consultation et nous réglons notre vitesse conformément à la direction où est exposé le bateau. Pour montrer de façon visuelle que le bateau est en train de plonger nous faisons un 'blit' entre l'image du bateau et celle du coup. Il nous faut donc MaskBitMapRastPort() car nous ne voulons pas que l'imagerie du coup détruise celle du bateau. Ensuite nous cherchons si le bateau a été touché la dernière fois. S'il ne l'a pas été, nous faisons démarrer le son du coup, qui se fera entendre tant qu'on ne l'arrêtera pas. Si l'on a pas appuyé sur la manette de commande et que le bateau avait coulé la fois précédente, nous mettons le son du coup hors service. LANCONS LES MISSILES. La procédure DoMissile() déplace et affiche chaque missile d'un joueur. Cette procédure détermine d'abord si le missile se trouve ou ne se trouve pas à l'écran en regardant ship[].lastshot et en comparant cette valeur avec zéro. Si cette valeur n'est pas égale à zéro, alors DoMissile() déplace le missile à sa vitesse, contrôle pour voir s'il se trouve toujours à l'écran, puis fait un 'blit' de l'image du missile dans la mappe de bits cachée appropriée. Quand enfin le missile sort de l'écran, la routine met ship[].lastshot à zéro, pour éviter qu'on fasse le rendu du missile pendant que le joueur exécute un nouveau tir. LA COLLISION : L'ACCIDENT ! Notre jeu ne serait pas très amusant si les objets ne se heurtaient pas l'un avec l'autre. Pour plus de simplicité, pour les collisions, on ne contrôle que les bateaux et les missiles du joueur opposé. Si l'on détecte une collision (le missile tombe à l'intérieur d'une zone du bateau de 10 pixels sur 10), on augmente le score et on l'affiche, on fait entendre le son du choc et le bateau détruit est remis à sa position de départ par la routine ResetShip(). AMELIORATIONS : NE VOUS ARRETEZ PAS MAINTENANT. Pour rester simple, j'ai laissé en dehors du programme plusieurs choses. Evidemment, ce qui manque le plus est la vérification des erreurs. Le programme fait très peu de vérifications, ce qui n'est pas vraiment bien. Une éventuelle exception est que vous demandiez à un joueur de lancer le programme sans qu'il n'y ait rien d'autre qui tourne en même temps. Ce n'est toujours pas une bonne idée, mais c'est une pratique courante avec plusieurs créateurs de jeux. Ajoutez une vérification des erreurs dans votre propre version. Voici quelques autres améliorations possibles : o Ouvrir un véritable écran par l'intermédiaire d'Intuition et permettre qu'on passe tous les événements d'entrée à Intuition, en laissant ainsi coexister plus facilement votre programme avec d'autres. Par conséquent, vous devrez toujours stopper les événements liés aux boutons de la souris quand l'écran destiné au jeu sera actif. Cette méthode permettra d'éviter qu'Intuition vous fasse part de ces événements, tout en lui permettant de les passer à d'autres programmes lorsque ceux-ci en ont besoin en entrée. o Un plan plus soigné pour la détection des collisions pourrait se faire par un AND logique entre l'image du bateau et celle du missile pour déterminer si une collision vient de se produire. Ceci permettrait de faire des appels plus rapprochés lorsque le missile se limite à effleurer le bateau. o Plus de huit vues donneraient lieu à un meilleur jeu. Ceci entraînerait un 'databm' et des tables de consultation plus importants. o Les collisions avec le soleil devraient être fatales, ainsi que devraient l'être celles entre bateaux. o Les missiles pourraient rebondir en dehors des limites ou alors on pourrait permettre aux bateaux et aux missiles de "boucler" autour de l'écran pour qu'un objet sortant d'un côté de l'écran puisse apparaître du côté opposé. o On pourrait ajouter des espaces supplémentaires ou des protections, si le joueur tire vers le bas sa manette de commande. o On pourrait ajouter une paire de joueurs supplémentaires en utilisant le gestionnaire logique du clavier en plus des manettes de commande. o On pourrait autoriser un joueur (le même) à tirer plusieurs missiles à l'écran en même temps. o Un bateau contrôlé par l'ordinateur serait une belle idée. Celui-ci aurait fort à faire avec ses vues, ainsi que pour les mises à feu et pour se déplacer partout. Il devrait essayer d'éviter les missiles et faire attention à ne pas s'envoler dans le soleil (si celui-ci était dangereux). VOUS VOICI SUR VOTRE PROPRE JEU. Une fois ces méthodes données, vous devriez être apte, à votre tour, de concevoir le plus chaud, le plus étonnant jeu de la décade ! Ces procédures sont les blocs de costruction de beaucoup de caractéristiques avancées qu'on rencontre dans la plupart des jeu du commerce. Ajoutez des blocs, modifiez, faites des expériences et amusez-vous. Le vrai défi, lorsqu'on conçoit un jeu, est d'arriver à développer un concept nouveau et unique qui attire et retient l'attention d'un joueur, mais je suis certain que vous en avez déjà beaucoup, n'est-ce pas ?